tools/installer: create common installer library

This creates a generic common installer WPF library for both games.
This commit is contained in:
lahm86 2025-03-24 20:49:33 +00:00
parent 2adaf2ac0f
commit e16fcda94b
51 changed files with 2737 additions and 0 deletions

16
tools/installer/.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
*.suo
*.o
*.obj
*.pdb
*.lib
*.exp
[Dd]ebug/
[Rr]elease/
[Oo]bj/
*.user
*.ipch
.vs/
*.vcxproj
*.filters
*.pubxml
[Oo]ut/

View file

@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.11.35219.272
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TRX_InstallerLib", "TRX_InstallerLib\TRX_InstallerLib.csproj", "{27F08E8C-2910-4682-B8BC-96ED4C1ECE54}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Debug|Any CPU.Build.0 = Debug|Any CPU
{27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Release|Any CPU.ActiveCfg = Release|Any CPU
{27F08E8C-2910-4682-B8BC-96ED4C1ECE54}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {BA21B1D5-1CC7-4ED8-8C79-A1A5B0ACC840}
EndGlobalSection
EndGlobal

View file

@ -0,0 +1,32 @@
<UserControl
x:Class="TRX_InstallerLib.Controls.FinishStepControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:TRX_InstallerLib.Models"
d:DataContext="{d:DesignInstance Type=models:FinishStep}"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<UserControl.Resources>
<ResourceDictionary Source="/TRX_InstallerLib;component/Resources/styles.xaml" />
</UserControl.Resources>
<StackPanel Orientation="Vertical">
<TextBlock TextWrapping="Wrap" VerticalAlignment="Center" Margin="0,0,0,12" Style="{StaticResource heading}">
Step 4: Done
</TextBlock>
<TextBlock VerticalAlignment="Center" Margin="0,0,0,12" TextWrapping="Wrap">
Installation complete. To configure more advanced features, you can edit the JSON files in the cfg/ directory with a text editor.
</TextBlock>
<TextBlock VerticalAlignment="Center" Margin="0,0,0,12">
Happy raiding :)
</TextBlock>
<CheckBox IsChecked="{Binding FinishSettings.OpenGameDirectory}" Content="Open game directory after closing this window" Margin="0,0,0,12" />
<CheckBox IsChecked="{Binding FinishSettings.LaunchGame}" Content="Launch the game after closing this window" Margin="0,0,0,12" />
</StackPanel>
</UserControl>

View file

@ -0,0 +1,11 @@
using WC = System.Windows.Controls;
namespace TRX_InstallerLib.Controls;
public partial class FinishStepControl : WC.UserControl
{
public FinishStepControl()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,146 @@
<UserControl
x:Class="TRX_InstallerLib.Controls.InstallSettingsStepControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:TRX_InstallerLib.Models"
xmlns:utils="clr-namespace:TRX_InstallerLib.Utils"
d:DataContext="{d:DesignInstance Type=models:InstallSettingsStep}"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<UserControl.Resources>
<ResourceDictionary Source="/TRX_InstallerLib;component/Resources/styles.xaml" />
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" TextWrapping="Wrap" VerticalAlignment="Center" Margin="0,0,0,12" Style="{StaticResource heading}">
Step 2: Installation options
</TextBlock>
<ScrollViewer Grid.Row="1" ScrollViewer.VerticalScrollBarVisibility="Auto">
<ScrollViewer.Template>
<ControlTemplate TargetType="{x:Type ScrollViewer}">
<Grid x:Name="Grid" Background="{TemplateBinding Background}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ScrollContentPresenter
Grid.Row="0"
Grid.Column="0"
x:Name="PART_ScrollContentPresenter"
CanContentScroll="{TemplateBinding CanContentScroll}"
CanHorizontallyScroll="False"
CanVerticallyScroll="False"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
Margin="{TemplateBinding Padding}" />
<Rectangle
Grid.Row="1"
Grid.Column="1"
x:Name="Corner"
Fill="{DynamicResource {x:Static SystemColors.ControlBrushKey}}" />
<ScrollBar
Grid.Row="0"
Grid.Column="1"
x:Name="PART_VerticalScrollBar"
AutomationProperties.AutomationId="VerticalScrollBar"
Cursor="Arrow"
Minimum="0"
Maximum="{TemplateBinding ScrollableHeight}"
Value="{Binding VerticalOffset, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}"
Visibility="{TemplateBinding ComputedVerticalScrollBarVisibility}"
ViewportSize="{TemplateBinding ViewportHeight}"
Margin="12,0,0,0" />
<ScrollBar
Grid.Row="1"
Grid.Column="0"
x:Name="PART_HorizontalScrollBar"
AutomationProperties.AutomationId="HorizontalScrollBar"
Orientation="Horizontal"
Cursor="Arrow"
Minimum="0"
Maximum="{TemplateBinding ScrollableWidth}"
Value="{Binding HorizontalOffset, Mode=OneWay, RelativeSource={RelativeSource TemplatedParent}}"
Visibility="{TemplateBinding ComputedHorizontalScrollBarVisibility}"
ViewportSize="{TemplateBinding ViewportWidth}" />
</Grid>
</ControlTemplate>
</ScrollViewer.Template>
<StackPanel Orientation="Vertical">
<CheckBox VerticalAlignment="Center" Margin="0,0,0,12" IsChecked="{Binding InstallSettings.DownloadMusic}" IsEnabled="{Binding InstallSettings.IsDownloadingMusicNeeded}">
<TextBlock TextWrapping="Wrap">
Download music tracks
<Run Foreground="ForestGreen" Text="{Binding InstallSettings.IsDownloadingMusicNeeded, Converter={utils:ConditionalMarkupConverter TrueValue='', FalseValue='(already found)'}, Mode=OneWay}" />
<LineBreak />
<Run Style="{StaticResource small}">
This option lets you download compatible music files for the game
automatically (60 MB). The legality of these files is disputable;
the most legal way to import the music to PC is to obtain them from
your own source - TR2 supports FLAC, OOG, MP3 and WAV files.
</Run>
</TextBlock>
</CheckBox>
<CheckBox VerticalAlignment="Center" Margin="0,0,0,6" IsChecked="{Binding InstallSettings.DownloadExpansionPack}" IsEnabled="{Binding InstallSettings.IsDownloadingExpansionNeeded}">
<StackPanel Orientation="Vertical">
<StackPanel.Resources>
<utils:ComparisonConverter x:Key="ComparisonConverter" />
</StackPanel.Resources>
<TextBlock TextWrapping="Wrap" Margin="0,0,0,6">
Download Unfinished Business expansion pack
<Run Foreground="ForestGreen" Text="{Binding InstallSettings.IsDownloadingExpansionNeeded, Converter={utils:ConditionalMarkupConverter TrueValue='', FalseValue='(already found)'}, Mode=OneWay}" />
<LineBreak />
<Run Style="{StaticResource small}">
The Unfinished Business expansion pack was made freeware. However, the Steam and GOG versions do not ship it. This option lets you download the expansion files automatically (6 MB).
</Run>
</TextBlock>
<RadioButton IsEnabled="{Binding InstallSettings.DownloadExpansionPack}" Style="{StaticResource small}" Margin="0,0,0,6" Content="Fan-made edition (includes music triggers)"
IsChecked="{Binding Path=InstallSettings.ExpansionPackType, Converter={StaticResource ComparisonConverter}, ConverterParameter={x:Static models:ExpansionPackType.Music}}"/>
<RadioButton IsEnabled="{Binding InstallSettings.DownloadExpansionPack}" Style="{StaticResource small}" Margin="0,0,0,6" Content="Original edition (does not include music triggers)"
IsChecked="{Binding Path=InstallSettings.ExpansionPackType, Converter={StaticResource ComparisonConverter}, ConverterParameter={x:Static models:ExpansionPackType.Vanilla}}"/>
</StackPanel>
</CheckBox>
<CheckBox VerticalAlignment="Center" Margin="0,0,0,12" IsChecked="{Binding InstallSettings.ImportSaves}" IsEnabled="{Binding InstallSettings.InstallSource.IsImportingSavesSupported}">
<TextBlock TextWrapping="Wrap">
Import saves
<LineBreak />
<Run Style="{StaticResource small}">
Imports existing savegame files. Only TombATI and TR1X savegame format is supported at this time.
</Run>
</TextBlock>
</CheckBox>
<CheckBox VerticalAlignment="Center" Margin="0,0,0,12" IsChecked="{Binding InstallSettings.CreateDesktopShortcut}">
Create desktop shortcut
</CheckBox>
<Separator />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Label Grid.Column="0" VerticalAlignment="Center" Margin="0,0,12,0" Padding="0" Content="Destination folder:" />
<TextBlock Grid.Column="1" VerticalAlignment="Center" Text="{Binding InstallSettings.TargetDirectory}" TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" VerticalAlignment="Center" Margin="12,0,0,0" Command="{Binding ChooseLocationCommand}" Content="Change..." />
</Grid>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using WC = System.Windows.Controls;
namespace TRX_InstallerLib.Controls;
public partial class InstallSettingsStepControl : WC.UserControl
{
public InstallSettingsStepControl()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,68 @@
<UserControl
x:Class="TRX_InstallerLib.Controls.InstallSourceControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:TRX_InstallerLib.Models"
xmlns:utils="clr-namespace:TRX_InstallerLib.Utils"
d:DataContext="{d:DesignInstance Type=models:InstallSourceViewModel}"
mc:Ignorable="d"
d:DesignHeight="450"
d:DesignWidth="800">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/TRX_InstallerLib;component/Resources/styles.xaml" />
<ResourceDictionary>
<utils:BoolToVisibilityConverter
x:Key="BoolToHiddenConverter"
FalseValue="Visible"
TrueValue="Collapsed" />
<utils:BoolToVisibilityConverter
x:Key="BoolToVisibleConverter"
FalseValue="Collapsed"
TrueValue="Visible" />
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="48" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Border Grid.Row="0" Grid.Column="0" Grid.RowSpan="2" Margin="0,0,12,0">
<Image Source="{Binding InstallSource.ImageSource}" Height="{Binding RelativeSource={RelativeSource AncestorType=Border}, Path=ActualWidth}" VerticalAlignment="Center" />
</Border>
<Grid Grid.Row="0" Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Padding="0" Margin="0,-3,12,3" VerticalAlignment="Top" Style="{StaticResource subHeading}" Text="{Binding InstallSource.SourceName}" Height="16" />
<TextBlock Grid.Column="1" Padding="0" Margin="0,-3,0,3" HorizontalAlignment="Right" VerticalAlignment="Top" Style="{StaticResource subHeadingFound}" Visibility="{Binding IsAvailable, Converter={StaticResource BoolToVisibleConverter}}" Text="Found" />
<TextBlock Grid.Column="1" Padding="0" Margin="0,-3,0,3" HorizontalAlignment="Right" VerticalAlignment="Top" Style="{StaticResource subHeadingNotFound}" Visibility="{Binding IsAvailable, Converter={StaticResource BoolToHiddenConverter}}" Text="Not found" />
</Grid>
<Grid Grid.Row="1" Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" VerticalAlignment="Center" Text="{Binding SourceDirectory, TargetNullValue='(no folder selected)'}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" VerticalAlignment="Center" Margin="6,0,0,0">
<Hyperlink Command="{Binding ChooseLocationCommand}" >(change)</Hyperlink>
</TextBlock>
</Grid>
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using WC = System.Windows.Controls;
namespace TRX_InstallerLib.Controls;
public partial class InstallSourceControl : WC.UserControl
{
public InstallSourceControl()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,44 @@
<UserControl
x:Class="TRX_InstallerLib.Controls.InstallStepControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:TRX_InstallerLib.Models"
d:DataContext="{d:DesignInstance Type=models:InstallStep}"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/TRX_InstallerLib;component/Resources/styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Vertical">
<TextBlock TextWrapping="Wrap" VerticalAlignment="Center" Margin="0,0,0,12" Style="{StaticResource heading}">
Step 3: Installing
</TextBlock>
<TextBlock VerticalAlignment="Center" Margin="0,0,0,12" Text="{Binding Description}" />
<ProgressBar VerticalAlignment="Center" Margin="0,0,0,12" Value="{Binding CurrentProgress}" Maximum="{Binding MaximumProgress}" MinHeight="16" />
</StackPanel>
<TextBox
x:Name="_logTextBox"
Grid.Row="1"
TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto"
AcceptsReturn="True"
IsReadOnly="True"/>
</Grid>
</UserControl>

View file

@ -0,0 +1,41 @@
using Installer.Models;
using System.Windows;
using TRX_InstallerLib.Models;
using WC = System.Windows.Controls;
namespace TRX_InstallerLib.Controls;
public partial class InstallStepControl : WC.UserControl
{
public InstallStepControl()
{
InitializeComponent();
DataContextChanged += (object sender, DependencyPropertyChangedEventArgs e) =>
{
var dataContext = DataContext as InstallStep;
if (dataContext is not null)
{
string? lastMessage = null;
dataContext.Logger.LogEvent += (object sender, LogEventArgs e) =>
{
if (e.Message != lastMessage)
{
lastMessage = e.Message;
AppendMessage(e.Message);
}
};
}
};
}
private void AppendMessage(string message)
{
_logTextBox.Dispatcher.Invoke(() =>
{
_logTextBox.AppendText(message + Environment.NewLine);
_logTextBox.Focus();
_logTextBox.CaretIndex = _logTextBox.Text.Length;
_logTextBox.ScrollToEnd();
});
}
}

View file

@ -0,0 +1,68 @@
<UserControl
x:Class="TRX_InstallerLib.Controls.SourceStepControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:controls="clr-namespace:TRX_InstallerLib.Controls"
xmlns:models="clr-namespace:TRX_InstallerLib.Models"
xmlns:utils="clr-namespace:TRX_InstallerLib.Utils"
d:DataContext="{d:DesignInstance Type=models:MainWindowViewModel}"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/TRX_InstallerLib;component/Resources/styles.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" MinHeight="30" />
<RowDefinition Height="Auto" MinHeight="30" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" TextWrapping="Wrap" VerticalAlignment="Center" Margin="0,0,0,12" Style="{StaticResource heading}">
Step 1: Choose installation source
</TextBlock>
<TextBlock Grid.Row="1" Grid.Column="0" TextWrapping="Wrap" VerticalAlignment="Center" Margin="0,0,0,10">
TR1X requires original game files to run.
<LineBreak />
Please choose the source location where to install the data files from.
<LineBreak />
If you're upgrading an existing installation, please choose TR1X.
</TextBlock>
<ListView
BorderThickness="0"
Grid.Row="2"
Grid.Column="0"
ItemsSource="{Binding InstallationSources}"
SelectedItem="{Binding SelectedInstallationSource, Mode=TwoWay}"
VerticalContentAlignment="Top"
HorizontalContentAlignment="Stretch"
ScrollViewer.CanContentScroll="False"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListView.ItemContainerStyle>
<Style TargetType="{x:Type ListViewItem}">
<Setter Property="Padding" Value="6" />
<Setter Property="Margin" Value="0,0,0,6" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate>
<controls:InstallSourceControl />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using WC = System.Windows.Controls;
namespace TRX_InstallerLib.Controls;
public partial class SourceStepControl : WC.UserControl
{
public SourceStepControl()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,107 @@
<Window
x:Class="TRX_InstallerLib.Controls.TRXInstallWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:TRX_InstallerLib.Controls"
xmlns:models="clr-namespace:TRX_InstallerLib.Models"
xmlns:utils="clr-namespace:TRX_InstallerLib.Utils"
d:DataContext="{d:DesignInstance Type=models:MainWindowViewModel}"
mc:Ignorable="d"
Title="{Binding ViewText[window_title_main]}"
MinWidth="480"
MinHeight="360"
Width="{Binding WindowWidth, Mode=OneWayToSource, UpdateSourceTrigger=PropertyChanged, FallbackValue=640}"
Height="500"
WindowStartupLocation="CenterScreen"
ResizeMode="CanResizeWithGrip">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="/TRX_InstallerLib;component/Resources/styles.xaml" />
<ResourceDictionary>
<utils:BoolToVisibilityConverter
x:Key="BoolToHiddenConverter"
FalseValue="Visible"
TrueValue="Collapsed" />
<utils:BoolToVisibilityConverter
x:Key="BoolToVisibleConverter"
FalseValue="Collapsed"
TrueValue="Visible" />
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Image
Grid.Row="0"
Grid.Column="0"
Source="{Binding CurrentStep.SidebarImage}"
Visibility="{Binding IsSidebarVisible, Converter={StaticResource BoolToVisibleConverter}}" />
<Border Grid.Row="0" Grid.Column="1">
<ContentControl Content="{Binding CurrentStep}" Margin="12,0,12,12">
<ContentControl.Resources>
<DataTemplate DataType="{x:Type models:SourceStep}">
<controls:SourceStepControl />
</DataTemplate>
<DataTemplate DataType="{x:Type models:InstallSettingsStep}">
<controls:InstallSettingsStepControl />
</DataTemplate>
<DataTemplate DataType="{x:Type models:InstallStep}">
<controls:InstallStepControl />
</DataTemplate>
<DataTemplate DataType="{x:Type models:FinishStep}">
<controls:FinishStepControl />
</DataTemplate>
</ContentControl.Resources>
</ContentControl>
</Border>
<Grid Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Background="{DynamicResource {x:Static SystemColors.ControlBrushKey}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.Resources>
<Style TargetType="{x:Type Button}" BasedOn="{StaticResource ButtonStyle}">
<Setter Property="Margin" Value="12,0,0,0" />
</Style>
</Grid.Resources>
<Grid Margin="12" Grid.Column="1" Grid.IsSharedSizeScope="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" SharedSizeGroup="SSG"/>
<ColumnDefinition Width="Auto" SharedSizeGroup="SSG"/>
<ColumnDefinition Width="Auto" SharedSizeGroup="SSG"/>
</Grid.ColumnDefinitions>
<Button
Command="{Binding GoToPreviousStepCommand}"
Visibility="{Binding IsFinalStep, Converter={StaticResource BoolToHiddenConverter}}"
Content="_Back" />
<Button
Grid.Column="1"
Command="{Binding GoToNextStepCommand}"
Visibility="{Binding IsFinalStep, Converter={StaticResource BoolToHiddenConverter}}"
Content="_Next" />
<Button
Grid.Column="2"
Command="{Binding CloseWindowCommand}"
CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}"
Content="{Binding IsFinalStep, Converter={utils:ConditionalMarkupConverter TrueValue='_Close', FalseValue='_Cancel'}}" />
</Grid>
</Grid>
</Grid>
</Window>

View file

@ -0,0 +1,14 @@
using System.Windows;
using TRX_InstallerLib.Installers;
using TRX_InstallerLib.Models;
namespace TRX_InstallerLib.Controls;
public partial class TRXInstallWindow : Window
{
public TRXInstallWindow(IEnumerable<IInstallSource> installSources)
{
InitializeComponent();
DataContext = new MainWindowViewModel(installSources);
}
}

View file

@ -0,0 +1,38 @@
using System.IO;
using TRX_InstallerLib.Models;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Installers;
public abstract class BaseInstallSource : IInstallSource
{
public abstract IEnumerable<string> DirectoriesToTry { get; }
public virtual string ImageSource
{
get => AssemblyUtils.GetEmbeddedResourcePath($"{SourceName}.png");
}
public abstract bool IsImportingSavesSupported { get; }
public abstract string SourceName { get; }
public virtual string SuggestedInstallationDirectory
{
get => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments),
TRXConstants.Instance.Game!);
}
public abstract Task CopyOriginalGameFiles(
string sourceDirectory,
string targetDirectory,
IProgress<InstallProgress> progress,
bool importSaves
);
public abstract bool IsDownloadingMusicNeeded(string sourceDirectory);
public abstract bool IsDownloadingExpansionNeeded(string sourceDirectory);
public abstract bool IsGameFound(string sourceDirectory);
}

View file

@ -0,0 +1,29 @@
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Installers;
public interface IInstallSource
{
public IEnumerable<string> DirectoriesToTry { get; }
public string ImageSource { get; }
public string SourceName { get; }
public string SuggestedInstallationDirectory { get; }
public Task CopyOriginalGameFiles(
string sourceDirectory,
string targetDirectory,
IProgress<InstallProgress> progress,
bool importSaves
);
bool IsDownloadingMusicNeeded(string sourceDirectory);
bool IsDownloadingExpansionNeeded(string sourceDirectory);
public bool IsGameFound(string sourceDirectory);
bool IsImportingSavesSupported { get; }
}

View file

@ -0,0 +1,103 @@
using System.IO;
using TRX_InstallerLib.Models;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Installers;
public class InstallExecutor
{
private static readonly string _resourceBaseURL;
static InstallExecutor()
{
_resourceBaseURL = $"https://lostartefacts.dev/aux/{TRXConstants.Instance.Game!.ToLower()}";
}
private readonly InstallSettings _settings;
public InstallExecutor(InstallSettings settings)
{
_settings = settings;
}
public IInstallSource? InstallSource
{
get => _settings.InstallSource;
}
public async Task ExecuteInstall(IProgress<InstallProgress> progress)
{
if (_settings.SourceDirectory is null)
{
throw new NullReferenceException();
}
if (_settings.TargetDirectory is null)
{
throw new NullReferenceException();
}
await CopyOriginalGameFiles(_settings.SourceDirectory, _settings.TargetDirectory, progress);
await CopyTRXFiles(_settings.TargetDirectory, progress);
if (_settings.DownloadMusic)
{
await DownloadMusicFiles(_settings.TargetDirectory, progress);
}
if (_settings.DownloadExpansionPack)
{
await DownloadExpansionFiles(_settings.TargetDirectory, _settings.ExpansionPackType, progress);
}
if (_settings.CreateDesktopShortcut)
{
CreateDesktopShortcut(_settings.TargetDirectory);
}
progress.Report(new InstallProgress { Description = "Finished", Finished = true });
}
protected async Task CopyOriginalGameFiles(string sourceDirectory, string targetDirectory, IProgress<InstallProgress> progress)
{
if (_settings.InstallSource is null)
{
throw new NullReferenceException();
}
await _settings.InstallSource.CopyOriginalGameFiles(sourceDirectory, targetDirectory, progress, _settings.ImportSaves);
}
protected static async Task CopyTRXFiles(string targetDirectory, IProgress<InstallProgress> progress)
{
InstallUtils.StoreInstallationPath(targetDirectory);
progress.Report(new InstallProgress
{
CurrentValue = 0,
MaximumValue = 1,
Description = "Opening embedded ZIP",
});
using var stream = AssemblyUtils.GetResourceStream("Resources.release.zip", false)
?? throw new ApplicationException($"Could not open embedded ZIP.");
await InstallUtils.ExtractZip(stream, targetDirectory, progress, overwrite: true);
}
protected static void CreateDesktopShortcut(string targetDirectory)
{
InstallUtils.CreateDesktopShortcut("TR1X", Path.Combine(targetDirectory, "TR1X.exe"));
if (File.Exists(Path.Combine(targetDirectory, "data", "cat.phd")))
{
InstallUtils.CreateDesktopShortcut("TR1X - UB", Path.Combine(targetDirectory, "TR1X.exe"), new[] { "-gold" });
}
}
protected static async Task DownloadMusicFiles(string targetDirectory, IProgress<InstallProgress> progress)
{
await InstallUtils.DownloadZip($"{_resourceBaseURL}/music.zip", targetDirectory, progress);
}
protected static async Task DownloadExpansionFiles(string targetDirectory, ExpansionPackType type, IProgress<InstallProgress> progress)
{
await InstallUtils.DownloadZip(
$"{_resourceBaseURL}/trub-{type.ToString().ToLower()}.zip",
targetDirectory, progress);
}
}

View file

@ -0,0 +1,207 @@
using Microsoft.Win32;
using System.IO;
using System.IO.Compression;
using System.Text.RegularExpressions;
using TRX_InstallerLib.Models;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Installers;
public static class InstallUtils
{
private static readonly string _registryStorageKey;
static InstallUtils()
{
_registryStorageKey = $@"Software\{TRXConstants.Instance.Game}";
}
public static async Task CopyDirectoryTree(
string sourceDirectory,
string targetDirectory,
IProgress<InstallProgress> progress,
Func<string, bool>? filterCallback = null,
Func<string, bool>? overwriteCallback = null
)
{
try
{
progress.Report(new InstallProgress { Description = "Scanning directory" });
var files = Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories);
var currentProgress = 0;
var maximumProgress = files.Length;
foreach (var sourcePath in files)
{
if (filterCallback is not null && !filterCallback(sourcePath))
{
continue;
}
var relPath = Path.GetRelativePath(sourceDirectory, sourcePath);
var targetPath = Path.Combine(targetDirectory, relPath);
var isSamePath = string.Equals(Path.GetFullPath(sourcePath), Path.GetFullPath(targetPath), StringComparison.OrdinalIgnoreCase);
if (!File.Exists(targetPath) || (overwriteCallback is not null && overwriteCallback(sourcePath) && !isSamePath))
{
progress.Report(new InstallProgress
{
CurrentValue = currentProgress,
MaximumValue = maximumProgress,
Description = $"Copying {relPath}",
});
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
await Task.Run(() => File.Copy(sourcePath, targetPath, true));
}
else
{
progress.Report(new InstallProgress
{
CurrentValue = currentProgress,
MaximumValue = maximumProgress,
Description = $"Copying {relPath} - skipped",
});
}
currentProgress++;
}
}
catch (Exception e)
{
throw new ApplicationException($"Could not extract ZIP:\n{e.Message}");
}
}
public static void CreateDesktopShortcut(string name, string targetPath, string[]? args = null)
{
var shortcutPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), $"{name}.lnk");
// TODO: pass extra arg for this
ShortcutUtils.CreateShortcut(shortcutPath, targetPath, "Tomb Raider I: Community Edition", args);
}
public static async Task<byte[]> DownloadFile(string url, IProgress<InstallProgress> progress)
{
HttpProgressClient wc = new();
progress.Report(new InstallProgress { Description = $"Initializing download of {url}" });
wc.DownloadProgressChanged += (totalBytesToReceive, bytesReceived) =>
{
progress.Report(new InstallProgress
{
CurrentValue = (int)bytesReceived,
MaximumValue = (int)totalBytesToReceive,
Description = $"Downloading {url}",
});
};
return await wc.DownloadDataTaskAsync(new Uri(url));
}
public static async Task DownloadZip(
string url,
string targetDirectory,
IProgress<InstallProgress> progress
)
{
var response = await DownloadFile(url, progress);
using var stream = new MemoryStream(response);
await ExtractZip(stream, targetDirectory, progress);
}
public static async Task ExtractZip(
Stream stream,
string targetDirectory,
IProgress<InstallProgress> progress,
Func<string, bool>? filterCallback = null,
bool overwrite = false
)
{
try
{
using var zip = new ZipArchive(stream);
progress.Report(new InstallProgress
{
Description = "Scanning ZIP",
});
var currentProgress = 0;
var maximumProgress = zip.Entries.Count;
foreach (var entry in zip.Entries)
{
if (new Regex(@"[\\/]$").IsMatch(entry.FullName))
{
continue;
}
if (filterCallback is not null && !filterCallback(entry.FullName))
{
continue;
}
var targetPath = Path.Combine(
targetDirectory,
new Regex(@"[\\/]").Replace(entry.FullName, Path.DirectorySeparatorChar.ToString()));
if (!File.Exists(targetPath) || overwrite)
{
progress.Report(new InstallProgress
{
CurrentValue = currentProgress,
MaximumValue = maximumProgress,
Description = $"Extracting {entry.FullName}",
});
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
await Task.Run(() => entry.ExtractToFile(targetPath, true));
}
else
{
progress.Report(new InstallProgress
{
CurrentValue = currentProgress,
MaximumValue = maximumProgress,
Description = $"Extracting {entry.FullName} - skipped",
});
}
currentProgress++;
}
}
catch (Exception e)
{
throw new ApplicationException($"Could not extract ZIP:\n{e.Message}");
}
}
public static IEnumerable<string> GetDesktopShortcutDirectories()
{
foreach (
var shortcutPath in Directory.EnumerateFiles(
Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), "*.lnk"
)
)
{
string? lnkPath;
try
{
lnkPath = ShortcutUtils.GetLnkTargetPath(shortcutPath);
}
catch (Exception)
{
continue;
}
if (lnkPath is not null)
{
var dirName = Path.GetDirectoryName(lnkPath);
if (dirName is not null)
{
yield return dirName;
}
}
}
}
public static void StoreInstallationPath(string installPath)
{
using var key = Registry.CurrentUser.CreateSubKey(_registryStorageKey);
key?.SetValue("InstallPath", installPath);
}
public static string? GetPreviousInstallationPath()
{
using var key = Registry.CurrentUser.OpenSubKey(_registryStorageKey);
return key?.GetValue("InstallPath")?.ToString();
}
}

View file

@ -0,0 +1,11 @@
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class BaseLanguageViewModel : BaseNotifyPropertyChanged
{
public static Dictionary<string, string> ViewText
{
get => Language.Instance.Controls ?? new();
}
}

View file

@ -0,0 +1,7 @@
namespace TRX_InstallerLib.Models;
public enum ExpansionPackType
{
Music,
Vanilla,
}

View file

@ -0,0 +1,35 @@
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class FinishSettings : BaseNotifyPropertyChanged
{
public bool LaunchGame
{
get => _launchGame;
set
{
if (value != _launchGame)
{
_launchGame = value;
NotifyPropertyChanged();
}
}
}
public bool OpenGameDirectory
{
get => _openGameDirectory;
set
{
if (value != _openGameDirectory)
{
_openGameDirectory = value;
NotifyPropertyChanged();
}
}
}
private bool _launchGame = false;
private bool _openGameDirectory = true;
}

View file

@ -0,0 +1,16 @@
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class FinishStep : BaseNotifyPropertyChanged, IStep
{
public FinishStep(FinishSettings finishSettings)
{
FinishSettings = finishSettings;
}
public bool CanProceedToNextStep => false;
public bool CanProceedToPreviousStep => false;
public FinishSettings FinishSettings { get; }
public string SidebarImage => AssemblyUtils.GetEmbeddedResourcePath("side4.jpg");
}

View file

@ -0,0 +1,10 @@
using System.ComponentModel;
namespace TRX_InstallerLib.Models;
public interface IStep : INotifyPropertyChanged
{
bool CanProceedToNextStep { get; }
bool CanProceedToPreviousStep { get; }
string SidebarImage { get; }
}

View file

@ -0,0 +1,155 @@
using TRX_InstallerLib.Installers;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class InstallSettings : BaseNotifyPropertyChanged
{
public bool CreateDesktopShortcut
{
get => _createDesktopShortcut;
set
{
if (value != _createDesktopShortcut)
{
_createDesktopShortcut = value;
NotifyPropertyChanged();
}
}
}
public bool DownloadMusic
{
get => _downloadMusic;
set
{
if (value != _downloadMusic)
{
_downloadMusic = value;
NotifyPropertyChanged();
}
}
}
public bool DownloadExpansionPack
{
get => _downloadExpansionPack;
set
{
if (value != _downloadExpansionPack)
{
_downloadExpansionPack = value;
NotifyPropertyChanged();
}
}
}
public bool AllowExpansionTypeSelection
{
get => _allowExpansionPackSelection;
private set
{
if (value != _allowExpansionPackSelection)
{
_allowExpansionPackSelection = value;
NotifyPropertyChanged();
}
}
}
public ExpansionPackType ExpansionPackType
{
get => _expansionPackType;
set
{
if (value != _expansionPackType)
{
_expansionPackType = value;
NotifyPropertyChanged();
}
}
}
public bool ImportSaves
{
get => _importSaves;
set
{
if (value != _importSaves)
{
_importSaves = value;
NotifyPropertyChanged();
}
}
}
public IInstallSource? InstallSource
{
get => _installSource;
set
{
if (value != _installSource)
{
_installSource = value;
DownloadMusic = SourceDirectory is not null && (_installSource?.IsDownloadingMusicNeeded(SourceDirectory) ?? false);
AllowExpansionTypeSelection = TRXConstants.Instance.AllowExpansionTypeSelection ?? false;
DownloadExpansionPack = SourceDirectory is not null && (_installSource?.IsDownloadingExpansionNeeded(SourceDirectory) ?? false);
ImportSaves = _installSource?.IsImportingSavesSupported ?? false;
TargetDirectory = _installSource?.SuggestedInstallationDirectory;
NotifyPropertyChanged();
}
}
}
public bool IsDownloadingMusicNeeded
{
get
{
return SourceDirectory is not null && (InstallSource?.IsDownloadingMusicNeeded(SourceDirectory) ?? false);
}
}
public bool IsDownloadingExpansionNeeded
{
get
{
return SourceDirectory is not null && (InstallSource?.IsDownloadingExpansionNeeded(SourceDirectory) ?? false);
}
}
public string? SourceDirectory
{
get => _sourceDirectory;
set
{
if (value != _sourceDirectory)
{
_sourceDirectory = value;
NotifyPropertyChanged();
}
}
}
public string? TargetDirectory
{
get => _targetDirectory;
set
{
if (value != _targetDirectory)
{
_targetDirectory = value;
NotifyPropertyChanged();
}
}
}
private bool _createDesktopShortcut = true;
private bool _downloadMusic;
private bool _downloadExpansionPack;
private bool _allowExpansionPackSelection;
private ExpansionPackType _expansionPackType;
private bool _importSaves;
private IInstallSource? _installSource;
private string? _sourceDirectory;
private string? _targetDirectory;
}

View file

@ -0,0 +1,37 @@
using System.Windows.Input;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class InstallSettingsStep : BaseNotifyPropertyChanged, IStep
{
public InstallSettingsStep(InstallSettings installSettings)
{
InstallSettings = installSettings;
InstallSettings.PropertyChanged += (sender, e) =>
{
NotifyPropertyChanged(nameof(CanProceedToNextStep));
};
}
public bool CanProceedToNextStep => InstallSettings.TargetDirectory != null;
public bool CanProceedToPreviousStep => true;
public InstallSettings InstallSettings { get; }
public string SidebarImage => AssemblyUtils.GetEmbeddedResourcePath("side2.jpg");
private RelayCommand? _chooseLocationCommand;
public ICommand ChooseLocationCommand
{
get => _chooseLocationCommand ??= new RelayCommand(ChooseLocation);
}
private void ChooseLocation()
{
var result = FileBrowser.Browse(InstallSettings.TargetDirectory);
if (result is not null)
{
InstallSettings.TargetDirectory = result;
}
}
}

View file

@ -0,0 +1,66 @@
using System.Windows.Input;
using TRX_InstallerLib.Installers;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class InstallSourceViewModel : BaseNotifyPropertyChanged
{
public InstallSourceViewModel(IInstallSource source)
{
InstallSource = source;
foreach (var directory in source.DirectoriesToTry)
{
if (InstallSource.IsGameFound(directory))
{
SourceDirectory = directory;
break;
}
}
}
public ICommand ChooseLocationCommand
{
get
{
return _chooseLocationCommand ??= new RelayCommand(ChooseLocation);
}
}
public IInstallSource InstallSource { get; private set; }
public bool IsAvailable
{
get
{
return SourceDirectory != null && InstallSource.IsGameFound(SourceDirectory);
}
}
public string? SourceDirectory
{
get => _sourceDirectory;
set
{
if (value != _sourceDirectory)
{
_sourceDirectory = value;
NotifyPropertyChanged();
NotifyPropertyChanged(nameof(IsAvailable));
}
}
}
private RelayCommand? _chooseLocationCommand;
private string? _sourceDirectory;
private void ChooseLocation()
{
var result = FileBrowser.Browse(SourceDirectory);
if (result is not null)
{
SourceDirectory = result;
}
}
}

View file

@ -0,0 +1,119 @@
using Installer.Models;
using TRX_InstallerLib.Installers;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class InstallStep : BaseNotifyPropertyChanged, IStep
{
public InstallStep(InstallSettings installSettings)
{
Logger = new Logger();
InstallSettings = installSettings;
}
public bool CanProceedToNextStep
{
get => _canProceedToNextStep;
set
{
if (value != _canProceedToNextStep)
{
_canProceedToNextStep = value;
NotifyPropertyChanged();
}
}
}
public bool CanProceedToPreviousStep => false;
public int CurrentProgress
{
get { return _currentProgress; }
set
{
if (value != _currentProgress)
{
_currentProgress = value;
NotifyPropertyChanged();
}
}
}
public string? Description
{
get => _description;
set
{
if (value != _description)
{
_description = value;
NotifyPropertyChanged();
}
}
}
public InstallSettings InstallSettings { get; }
public Logger Logger { get; }
public int MaximumProgress
{
get { return _maximumProgress; }
set
{
if (value != _maximumProgress)
{
_maximumProgress = value;
NotifyPropertyChanged();
NotifyPropertyChanged(nameof(CanProceedToNextStep));
}
}
}
public string SidebarImage => AssemblyUtils.GetEmbeddedResourcePath("side3.jpg");
public void RunInstall()
{
var progress = new Progress<InstallProgress>();
progress.ProgressChanged += (sender, progress) =>
{
if (progress.CurrentValue is not null && progress.MaximumValue is not null)
{
CurrentProgress = progress.CurrentValue.Value;
MaximumProgress = progress.MaximumValue.Value;
}
else
{
CurrentProgress = progress.Finished ? 1 : 0;
MaximumProgress = 1;
}
Description = progress.Description;
if (progress.Description is not null)
{
Logger.RaiseLogEvent(progress.Description);
}
if (progress.Finished)
{
CanProceedToNextStep = true;
}
};
Task.Run(async () =>
{
try
{
var executor = new InstallExecutor(InstallSettings);
await executor.ExecuteInstall(progress);
}
catch (Exception ex)
{
Logger.RaiseLogEvent(ex.ToString());
}
});
}
private bool _canProceedToNextStep;
private int _currentProgress = 0;
private string? _description;
private int _maximumProgress = 1;
}

View file

@ -0,0 +1,46 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Globalization;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class Language
{
private static readonly string _langPathFormat = "Resources.Lang.{0}.json";
private static readonly string _defaultCulture = "en-US";
public static Language Instance { get; private set; }
public Dictionary<string, string>? Controls { get; set; }
static Language()
{
CultureInfo defaultCulture = CultureInfo.GetCultureInfo(_defaultCulture);
JObject defaultData = ReadLanguage(defaultCulture.TwoLetterISOLanguageName);
if (CultureInfo.CurrentCulture != defaultCulture)
{
// Merge the main language first if it exists, and then the country specific if that exists.
// e.g. fr.json would load first, then fr-BE.json.
MergeLanguage(defaultData, CultureInfo.CurrentCulture.TwoLetterISOLanguageName);
MergeLanguage(defaultData, CultureInfo.CurrentCulture.Name);
}
Instance = JsonConvert.DeserializeObject<Language>(defaultData.ToString())!;
}
private static JObject ReadLanguage(string tag)
{
return JsonUtils.LoadEmbeddedResource(string.Format(_langPathFormat, tag)) ?? new();
}
private static void MergeLanguage(JObject data, string tag)
{
JObject cultureData = ReadLanguage(tag);
if (cultureData != null)
{
data.Merge(cultureData);
}
}
}

View file

@ -0,0 +1,23 @@
namespace Installer.Models;
public class LogEventArgs
{
public LogEventArgs(string message)
{
Message = message;
}
public string Message { get; }
}
public class Logger
{
public delegate void LogEventHandler(object sender, LogEventArgs e);
public event LogEventHandler? LogEvent;
public void RaiseLogEvent(string message)
{
LogEvent?.Invoke(this, new LogEventArgs(message));
}
}

View file

@ -0,0 +1,152 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using System.Windows.Input;
using TRX_InstallerLib.Installers;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class MainWindowViewModel : BaseLanguageViewModel
{
public MainWindowViewModel(IEnumerable<IInstallSource> installSources)
{
_sourceStep = new SourceStep(installSources);
_currentStep = _sourceStep;
_installSettings = new InstallSettings();
}
public ICommand CloseWindowCommand
{
get => _closeWindowCommand ??= new RelayCommand<Window>(CloseWindow);
}
public IStep CurrentStep
{
get => _currentStep;
set
{
_currentStep = value;
_goToPreviousStepCommand?.RaiseCanExecuteChanged();
_goToNextStepCommand?.RaiseCanExecuteChanged();
_currentStep.PropertyChanged += (sender, e) =>
{
_goToPreviousStepCommand?.RaiseCanExecuteChanged();
_goToNextStepCommand?.RaiseCanExecuteChanged();
};
NotifyPropertyChanged();
NotifyPropertyChanged(nameof(IsFinalStep));
}
}
public ICommand GoToNextStepCommand
{
get => _goToNextStepCommand ??= new RelayCommand(GoToNextStep, CanGoToNextStep);
}
public ICommand GoToPreviousStepCommand
{
get => _goToPreviousStepCommand ??= new RelayCommand(GoToPreviousStep, CanGoToPreviousStep);
}
public bool IsFinalStep
{
get => CurrentStep is FinishStep;
}
public bool IsSidebarVisible
{
get => WindowWidth >= 500;
}
public int WindowWidth
{
get => _windowWidth;
set
{
if (value != _windowWidth)
{
_windowWidth = value;
NotifyPropertyChanged(nameof(IsSidebarVisible));
}
}
}
private const bool _autoFinishInstallStep = false;
private RelayCommand<Window>? _closeWindowCommand;
private IStep _currentStep;
private FinishSettings? _finishSettings;
private RelayCommand? _goToNextStepCommand;
private RelayCommand? _goToPreviousStepCommand;
private readonly InstallSettings _installSettings;
private readonly IStep _sourceStep;
private int _windowWidth;
private bool CanGoToNextStep()
{
return CurrentStep.CanProceedToNextStep;
}
private bool CanGoToPreviousStep()
{
return CurrentStep.CanProceedToPreviousStep;
}
private void CloseWindow(Window? window)
{
if (_finishSettings is not null && _finishSettings.LaunchGame)
{
if (_installSettings.TargetDirectory is null)
{
throw new NullReferenceException();
}
Process.Start(Path.Combine(_installSettings.TargetDirectory, "TR1X.exe"));
}
if (_finishSettings is not null && _finishSettings.OpenGameDirectory)
{
if (_installSettings.TargetDirectory is null)
{
throw new NullReferenceException();
}
Process.Start("explorer.exe", _installSettings.TargetDirectory);
}
window?.Close();
}
private void GoToNextStep()
{
if (CurrentStep is SourceStep sourceStep)
{
var installSource = sourceStep.SelectedInstallationSource!.InstallSource;
_installSettings.InstallSource = installSource;
_installSettings.SourceDirectory = sourceStep.SelectedInstallationSource.SourceDirectory;
CurrentStep = new InstallSettingsStep(_installSettings);
}
else if (CurrentStep is InstallSettingsStep targetStep)
{
var installStep = new InstallStep(targetStep.InstallSettings);
installStep.RunInstall();
installStep.PropertyChanged += (sender, e) =>
{
if (_autoFinishInstallStep && installStep.CanProceedToNextStep)
{
_finishSettings = new FinishSettings();
CurrentStep = new FinishStep(_finishSettings);
}
};
CurrentStep = installStep;
}
else if (CurrentStep is InstallStep)
{
_finishSettings = new FinishSettings();
CurrentStep = new FinishStep(_finishSettings);
}
}
private void GoToPreviousStep()
{
CurrentStep = _sourceStep;
}
}

View file

@ -0,0 +1,60 @@
using System.Collections.ObjectModel;
using TRX_InstallerLib.Installers;
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class SourceStep : BaseNotifyPropertyChanged, IStep
{
public SourceStep(IEnumerable<IInstallSource> installSources)
{
// NOTE: the order also decides which installation source will be selected by default
InstallationSources = new ObservableCollection<InstallSourceViewModel>
(installSources.Select(i => new InstallSourceViewModel(i)));
foreach (var installationSource in InstallationSources)
{
installationSource.PropertyChanged += (sender, e) =>
{
NotifyPropertyChanged(nameof(InstallationSources));
if (installationSource == selectedInstallationSource)
{
NotifyPropertyChanged(nameof(SelectedInstallationSource));
}
};
}
foreach (var source in InstallationSources)
{
if (source.IsAvailable)
{
SelectedInstallationSource = source;
}
}
}
public bool CanProceedToNextStep
{
get => SelectedInstallationSource != null && SelectedInstallationSource.IsAvailable;
}
public bool CanProceedToPreviousStep => false;
public IEnumerable<InstallSourceViewModel> InstallationSources { get; private set; }
public InstallSourceViewModel? SelectedInstallationSource
{
get => selectedInstallationSource;
set
{
if (value != selectedInstallationSource)
{
selectedInstallationSource = value;
NotifyPropertyChanged();
NotifyPropertyChanged(nameof(CanProceedToNextStep));
}
}
}
public string SidebarImage => AssemblyUtils.GetEmbeddedResourcePath("side1.jpg");
private InstallSourceViewModel? selectedInstallationSource;
}

View file

@ -0,0 +1,19 @@
using TRX_InstallerLib.Utils;
namespace TRX_InstallerLib.Models;
public class TRXConstants
{
private static readonly string _constConfigPath = "Resources.const.json";
public static TRXConstants Instance { get; private set; }
static TRXConstants()
{
Instance = JsonUtils.LoadEmbeddedResource(_constConfigPath)?.ToObject<TRXConstants>() ?? new();
}
public string? Game { get; set; }
public bool? AllowExpansionTypeSelection { get; set; }
}

View file

@ -0,0 +1,5 @@
{
"Controls": {
}
}

View file

@ -0,0 +1,4 @@
{
"Game": "TRX",
"AllowExpansionTypeSelection": false
}

View file

@ -0,0 +1,32 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style TargetType="{x:Type Button}" x:Key="ButtonStyle">
<Setter Property="Padding" Value="10,5" />
<Setter Property="MinWidth" Value="70" />
</Style>
<Style TargetType="{x:Type Button}" BasedOn="{StaticResource ButtonStyle}" />
<Style x:Key="heading" TargetType="TextBlock">
<Setter Property="FontSize" Value="20" />
</Style>
<Style x:Key="subHeading" TargetType="TextBlock">
<Setter Property="FontSize" Value="15" />
</Style>
<Style x:Key="subHeadingFound" TargetType="TextBlock" BasedOn="{StaticResource subHeading}">
<Setter Property="Foreground" Value="ForestGreen" />
</Style>
<Style x:Key="subHeadingNotFound" TargetType="TextBlock" BasedOn="{StaticResource subHeading}">
<Setter Property="Foreground" Value="Firebrick" />
</Style>
<Style x:Key="normal" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
</Style>
<Style x:Key="small">
<Setter Property="Control.FontSize" Value="10" />
<Setter Property="Run.FontSize" Value="10" />
</Style>
<Style TargetType="Image">
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
</Style>
</ResourceDictionary>

View file

@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
<ItemGroup>
<None Remove="Resources\const.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\const.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<Resource Include="Resources\styles.xaml">
<Generator>MSBuild:Compile</Generator>
</Resource>
</ItemGroup>
</Project>

View file

@ -0,0 +1,35 @@
using System.IO;
using System.Reflection;
namespace TRX_InstallerLib.Utils;
public static class AssemblyUtils
{
public static readonly string _resourcePathFormat = "pack://application:,,,/{0};component/Resources/{1}";
private static Assembly? GetReferencedAssembly(bool local)
{
return local ? Assembly.GetExecutingAssembly() : Assembly.GetEntryAssembly();
}
public static Stream GetResourceStream(string relativePath, bool local)
{
return GetReferencedAssembly(local)?.GetManifestResourceStream(GetAbsolutePath(relativePath, local))!;
}
public static bool ResourceExists(string relativePath, bool local)
{
return GetReferencedAssembly(local)?.GetManifestResourceNames()
.Contains(GetAbsolutePath(relativePath, local)) ?? false;
}
public static string GetAbsolutePath(string relativePath, bool local)
{
return $"{GetReferencedAssembly(local)!.GetName().Name}.{relativePath}";
}
public static string GetEmbeddedResourcePath(string resource)
{
return string.Format(_resourcePathFormat, Assembly.GetEntryAssembly()!.GetName().Name, resource);
}
}

View file

@ -0,0 +1,14 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace TRX_InstallerLib.Utils;
public abstract class BaseNotifyPropertyChanged : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View file

@ -0,0 +1,30 @@
using System.IO;
using System.Text;
namespace TRX_InstallerLib.Utils;
public static class BinaryReaderExtensions
{
public static string ReadNullTerminatedString(this BinaryReader stream)
{
string str = "";
char ch;
while ((int)(ch = stream.ReadChar()) != 0)
{
str += ch;
}
return str;
}
public static string ReadSystemCodepageString(this BinaryReader stream)
{
var length = stream.ReadUInt16();
return Encoding.Default.GetString(stream.ReadBytes(length));
}
public static string ReadUtf16String(this BinaryReader stream)
{
var length = stream.ReadUInt16();
return Encoding.Unicode.GetString(stream.ReadBytes(length * 2));
}
}

View file

@ -0,0 +1,28 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace TRX_InstallerLib.Utils;
[ValueConversion(typeof(bool), typeof(Visibility))]
public class BoolToVisibilityConverter : IValueConverter
{
public BoolToVisibilityConverter()
{
FalseValue = Visibility.Hidden;
TrueValue = Visibility.Visible;
}
public Visibility FalseValue { get; set; }
public Visibility TrueValue { get; set; }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return (bool)value ? TrueValue : FalseValue;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,17 @@
using System.Globalization;
using WD = System.Windows.Data;
namespace TRX_InstallerLib.Utils;
public class ComparisonConverter : WD.IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value.Equals(parameter);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return (bool)value ? parameter : WD.Binding.DoNothing;
}
}

View file

@ -0,0 +1,26 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Markup;
namespace TRX_InstallerLib.Utils;
public sealed class ConditionalMarkupConverter : MarkupExtension, IValueConverter
{
public object FalseValue { get; set; } = new();
public object TrueValue { get; set; } = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is true ? TrueValue : FalseValue;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
public override object ProvideValue(IServiceProvider serviceProvider)
{
return this;
}
}

View file

@ -0,0 +1,89 @@
using System.IO;
using System.Text.RegularExpressions;
namespace TRX_InstallerLib.Utils;
public class CueFile
{
public readonly List<CueTrack> TrackList = new();
public CueFile(string cueFilePath)
{
_cueFilePath = cueFilePath;
string cueFileContent;
using (TextReader cueReader = new StreamReader(cueFilePath))
{
cueFileContent = cueReader.ReadToEnd();
}
MatchCollection fileMatches = _fileGroupRegex.Matches(cueFileContent);
if (fileMatches.Count == 0)
{
throw new ApplicationException($"Could not parse {cueFilePath}: no tracks were found");
}
foreach (Match fileMatch in fileMatches.Cast<Match>())
{
var binFilePath = GetBinFilePath(fileMatch.Groups["name"].Value.Trim('"'));
var matches = _trackRegex.Matches(fileMatch.Groups["content"].Value);
if (matches.Count == 0)
{
throw new ApplicationException($"Could not parse {cueFilePath}: no tracks were found");
}
CueTrack? track = null;
CueTrack? prevTrack = null;
foreach (Match trackMatch in matches.Cast<Match>())
{
track = new CueTrack(
binFilePath,
int.Parse(trackMatch.Groups["track"].Value),
trackMatch.Groups["mode"].Value,
trackMatch.Groups["time"].Value);
if (prevTrack != null)
{
prevTrack.Stop = track.StartPosition - 1;
prevTrack.StopSector = track.StartSector;
}
TrackList.Add(track);
prevTrack = track;
}
if (track == null)
{
return;
}
track.Stop = GetBinFileLength(binFilePath);
track.StopSector = track.Stop / CueTrack.SectorLength;
}
}
private static readonly Regex _fileGroupRegex = new(
@"^file\s+(?<name>""[^""]+""|[^""\s]+)\s+(?<mode>\w+)\s+(?<content>(.(?!^file))*)",
RegexOptions.IgnoreCase | RegexOptions.Multiline | RegexOptions.Singleline);
private static readonly Regex _trackRegex = new(@"track\s+?(?<track>\d+?)\s+?(?<mode>\S+?)[\s$]+?index\s+?\d+?\s+?(?<time>\S*)",
RegexOptions.IgnoreCase | RegexOptions.Multiline);
private readonly string _cueFilePath;
private static long GetBinFileLength(string binFilePath)
{
FileInfo fileInfo = new(binFilePath);
return fileInfo.Length;
}
private string GetBinFilePath(string name)
{
var cueDirectory = Path.GetDirectoryName(_cueFilePath)!;
string result = Path.Combine(cueDirectory, Path.GetFileName(name));
if (!File.Exists(result))
{
result = Path.Combine(cueDirectory, Path.GetFileNameWithoutExtension(_cueFilePath) + ".bin");
}
return result;
}
}

View file

@ -0,0 +1,230 @@
using System.IO;
namespace TRX_InstallerLib.Utils;
public class CueTrack
{
public const int SectorLength = 2352;
public bool Audio;
public long StopSector;
public bool SwapAudioByteOrder;
public bool TruncatePsx;
public bool WavFormat;
public CueTrack(string binFilePath, int trackNumber, string mode, string time)
{
BinFilePath = binFilePath;
TrackNumber = trackNumber;
SetMode(mode);
StartSector = ToFrames(time);
}
public enum TrackExtension
{
ISO, CDR, WAV, UGH
}
public string BinFilePath { get; private set; }
public int BlockSize { get; private set; }
public int BlockStart { get; private set; }
public TrackExtension FileExtension { get; private set; }
public long StartPosition
{
get { return StartSector * SectorLength; }
}
public long StartSector { get; private set; }
public long Stop { get; set; }
public long TotalBytes
{
get { return (StopSector - StartSector + 1) * BlockSize; }
}
public int TrackNumber { get; private set; }
public void Write(string targetPath, IProgress<InstallProgress> progress)
{
using FileStream fileStream = OpenBinFile();
try
{
using Stream stream = File.OpenWrite(targetPath);
if (Audio && WavFormat)
{
byte[] header = MakeWavHeader(TotalBytes);
stream.Write(header, 0, header.Length);
}
long currentPosition = StartPosition;
long sector = StartSector;
long convertedBytes = 0;
byte[] buf = new byte[SectorLength];
while (sector <= StopSector && fileStream.Read(buf, 0, SectorLength) > 0)
{
if (Audio && SwapAudioByteOrder)
{
DoByteSwap(buf);
}
stream.Write(buf, BlockStart, BlockSize);
currentPosition += SectorLength;
convertedBytes += BlockSize;
if (currentPosition / SectorLength % 500 == 0)
{
progress.Report(new InstallProgress
{
MaximumValue = (int)TotalBytes,
CurrentValue = (int)convertedBytes,
Description = "Converting BIN to ISO"
});
}
sector++;
}
}
catch (Exception e)
{
throw new ApplicationException(string.Format(" Could not write to track file {0}: {1}", targetPath, e.Message));
}
progress.Report(new InstallProgress
{
MaximumValue = (int)TotalBytes,
CurrentValue = (int)TotalBytes,
Description = "Converting BIN to ISO",
});
}
private static byte[] MakeWavHeader(long length)
{
const int WAV_RIFF_HLEN = 12;
const int WAV_FORMAT_HLEN = 24;
const int WAV_DATA_HLEN = 8;
const int WAV_HEADER_LEN = WAV_RIFF_HLEN + WAV_FORMAT_HLEN + WAV_DATA_HLEN;
MemoryStream memoryStream = new(WAV_HEADER_LEN);
using (BinaryWriter writer = new(memoryStream))
{
// RIFF header
writer.Write("RIFF".ToCharArray());
uint dwordValue = (uint)length + WAV_DATA_HLEN + WAV_FORMAT_HLEN + 4;
writer.Write(dwordValue); // length of file, starting from WAVE
writer.Write("WAVE".ToCharArray());
// FORMAT header
writer.Write("fmt ".ToCharArray());
dwordValue = 0x10; // length of FORMAT header
writer.Write(dwordValue);
ushort wordValue = 0x01; // constant
writer.Write(wordValue);
wordValue = 0x02; // channels
writer.Write(wordValue);
dwordValue = 44100; // sample rate
writer.Write(dwordValue);
dwordValue = 44100 * 4; // bytes per second
writer.Write(dwordValue);
wordValue = 4; // bytes per sample
writer.Write(wordValue);
wordValue = 2 * 8; // bits per channel
writer.Write(wordValue);
// DATA header
writer.Write("data".ToCharArray());
dwordValue = (uint)length;
writer.Write(dwordValue);
}
return memoryStream.ToArray();
}
private static long ToFrames(string time)
{
string[] segs = time.Split(':');
int mins = int.Parse(segs[0]);
int secs = int.Parse(segs[1]);
int frames = int.Parse(segs[2]);
return (mins * 60 + secs) * 75 + frames;
}
private void DoByteSwap(byte[] buf)
{
// swap low and high bytes
int p = BlockStart;
int ep = BlockSize;
while (p < ep)
{
(buf[p + 1], buf[p]) = (buf[p], buf[p + 1]);
p += 2;
}
}
private FileStream OpenBinFile()
{
FileStream fileStream;
try
{
fileStream = File.OpenRead(BinFilePath);
}
catch (Exception e)
{
throw new ApplicationException($"Could not open BIN {BinFilePath}: {e.Message}");
}
try
{
fileStream.Seek(StartPosition, SeekOrigin.Begin);
}
catch (Exception e)
{
throw new ApplicationException(string.Format("Could not seek to track location: {0}", e.Message));
}
return fileStream;
}
private void SetMode(string mode)
{
Audio = false;
BlockStart = 0;
FileExtension = TrackExtension.ISO;
switch (mode.ToUpper())
{
case "AUDIO":
BlockSize = 2352;
Audio = true;
FileExtension = WavFormat ? TrackExtension.WAV : TrackExtension.CDR;
break;
case "MODE1/2352":
BlockStart = 16;
BlockSize = 2048;
break;
case "MODE2/2336":
// WAS 2352 in V1.361B still work? What if MODE2/2336 single track bin, still 2352 sectors?
BlockStart = 16;
BlockSize = 2336;
break;
case "MODE2/2352":
if (TruncatePsx)
{
// PSX: truncate from 2352 to 2336 byte tracks
BlockSize = 2336;
}
else
{
// Normal MODE2/2352
BlockStart = 24;
BlockSize = 2048;
}
break;
default:
BlockSize = 2352;
FileExtension = TrackExtension.UGH;
break;
}
}
}

View file

@ -0,0 +1,19 @@
namespace TRX_InstallerLib.Utils;
public class FileBrowser
{
public static string? Browse(string? initialDirectory)
{
using var dlg = new FolderBrowserDialog()
{
Description = "Choose directory",
SelectedPath = initialDirectory,
ShowNewFolderButton = true,
};
if (dlg.ShowDialog() == DialogResult.OK)
{
return dlg.SelectedPath;
}
return null;
}
}

View file

@ -0,0 +1,50 @@
using System.IO;
using System.Net.Http;
namespace TRX_InstallerLib.Utils;
public class HttpProgressClient
{
public delegate void ProgressChangedHandler(long totalBytesToReceive, long bytesReceived);
public event ProgressChangedHandler? DownloadProgressChanged;
public async Task<byte[]> DownloadDataTaskAsync(Uri uri)
{
using HttpClient client = new();
client.DefaultRequestHeaders.CacheControl = new()
{
NoCache = true
};
HttpResponseMessage response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
long totalBytes = response.Content.Headers.ContentLength ?? 0;
using Stream contentStream = await response.Content.ReadAsStreamAsync();
return await ProcessContentStream(totalBytes, contentStream);
}
private async Task<byte[]> ProcessContentStream(long totalBytes, Stream contentStream)
{
long totalBytesRead = 0;
byte[] buffer = new byte[8192];
using MemoryStream outputStream = new();
while (true)
{
int bytesRead = await contentStream.ReadAsync(buffer);
if (bytesRead == 0)
{
break;
}
await outputStream.WriteAsync(buffer.AsMemory(0, bytesRead));
totalBytesRead += bytesRead;
DownloadProgressChanged?.Invoke(totalBytes, totalBytesRead);
}
return outputStream.ToArray();
}
}

View file

@ -0,0 +1,9 @@
namespace TRX_InstallerLib.Utils;
public class InstallProgress
{
public int? CurrentValue { get; set; }
public string? Description { get; set; }
public bool Finished { get; set; }
public int? MaximumValue { get; set; }
}

View file

@ -0,0 +1,31 @@
using Newtonsoft.Json.Linq;
using System.IO;
namespace TRX_InstallerLib.Utils;
public static class JsonUtils
{
public static JObject? LoadEmbeddedResource(string path)
{
// Try to locate the data in this assembly first, then merge it
// with the same in the entry assembly if relevant.
JObject? data = null;
if (AssemblyUtils.ResourceExists(path, true))
{
using Stream stream = AssemblyUtils.GetResourceStream(path, true);
using StreamReader reader = new(stream);
data = JObject.Parse(reader.ReadToEnd());
}
if (AssemblyUtils.ResourceExists(path, false))
{
data ??= new();
using Stream stream = AssemblyUtils.GetResourceStream(path, false);
using StreamReader reader = new(stream);
data.Merge(JObject.Parse(reader.ReadToEnd()));
}
return data;
}
}

View file

@ -0,0 +1,20 @@
using System.Diagnostics;
using System.IO;
namespace TRX_InstallerLib.Utils;
public static class ProcessUtils
{
public static void Start(string fileName, string? arguments = null)
{
Process.Start(new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
UseShellExecute = true,
WorkingDirectory = new Uri(fileName).IsFile
? Path.GetDirectoryName(fileName)
: null
});
}
}

View file

@ -0,0 +1,101 @@
using System.Windows.Input;
namespace TRX_InstallerLib.Utils;
public class RelayCommand : ICommand
{
public RelayCommand(Action execute, Func<bool>? canExecute)
{
_execute = execute;
_canExecute = canExecute;
}
public RelayCommand(Action execute)
{
_execute = execute;
_canExecute = null;
}
public event EventHandler? CanExecuteChanged
{
add
{
CommandManager.RequerySuggested += value;
_canExecuteChanged += value;
}
remove
{
CommandManager.RequerySuggested -= value;
_canExecuteChanged -= value;
}
}
public bool CanExecute(object? parameter)
{
return _canExecute == null || _canExecute();
}
public void Execute(object? parameter)
{
_execute();
}
public void RaiseCanExecuteChanged()
{
_canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
private readonly Func<bool>? _canExecute;
private readonly Action _execute;
private EventHandler? _canExecuteChanged;
}
public class RelayCommand<T> : ICommand
{
public RelayCommand(Action<T?> execute, Func<T?, bool>? canExecute)
{
_execute = execute;
_canExecute = canExecute;
}
public RelayCommand(Action<T?> execute)
{
_execute = execute;
_canExecute = null;
}
public event EventHandler? CanExecuteChanged
{
add
{
CommandManager.RequerySuggested += value;
_canExecuteChanged += value;
}
remove
{
CommandManager.RequerySuggested -= value;
_canExecuteChanged -= value;
}
}
public bool CanExecute(object? parameter)
{
return _canExecute == null || _canExecute((T?)parameter);
}
public void Execute(object? parameter)
{
_execute((T?)parameter);
}
public void RaiseCanExecuteChanged()
{
_canExecuteChanged?.Invoke(this, EventArgs.Empty);
}
private readonly Func<T?, bool>? _canExecute;
private readonly Action<T?> _execute;
private EventHandler? _canExecuteChanged;
}

View file

@ -0,0 +1,235 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace TRX_InstallerLib.Utils;
public static class ShortcutUtils
{
public static void CreateShortcut(string shortcutPath, string targetPath, string name, string[]? args = null)
{
var fileInfo = File.Exists(targetPath) ? new FileInfo(targetPath) : null;
using var stream = File.Open(Path.ChangeExtension(shortcutPath, "lnk"), FileMode.Create);
using var bw = new BinaryWriter(stream);
void writeShellLinkHeader()
{
// HeaderSize
bw.Write(0x4C);
// LinkCLSID
bw.Write(new byte[] { 0x01, 0x14, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46 });
// LinkFlags
bw.Write(
(1 << 0) // HasLinkTargetIDList
| (1 << 2) // HasName
| (1 << 3) // HasRelativePath
| (1 << 4) // HasWorkingDir
| (1 << 5) // HasArguments
| (1 << 7) // IsUnicode
| (1 << 8) // ForceNoLinkInfo
);
if (fileInfo is not null)
{
bw.Write((int)fileInfo.Attributes); // FileAttributes
bw.Write(fileInfo.CreationTimeUtc.ToFileTime()); // CreationTime
bw.Write(fileInfo.LastAccessTimeUtc.ToFileTime()); // AccessTime
bw.Write(fileInfo.LastWriteTimeUtc.ToFileTime()); // WriteTime
bw.Write((int)fileInfo.Length); // FileSize
}
else
{
bw.Write(0); // FileAttributes
bw.Write((long)0); // CreationTime
bw.Write((long)0); // AccessTime
bw.Write((long)0); // WriteTime
bw.Write(0); // FileSize
}
bw.Write(0); // IconIndex
bw.Write(1); // ShowCommand - SW_SHOWNORMAL
bw.Write((short)0); // HotKey
bw.Write((short)0); // Reserved1
bw.Write(0); // Reserved2
bw.Write(0); // Reserved3
}
void writeLinkTargetIDList()
{
var idListSizePos = (int)bw.BaseStream.Position;
bw.Write((ushort)0); // IDListSize
// CLSID for this computer
bw.Write((short)(0x12 + 2));
bw.Write(new byte[] { 0x1F, 0x50, 0xE0, 0x4F, 0xD0, 0x20, 0xEA, 0x3A, 0x69, 0x10, 0xA2, 0xD8, 0x08, 0x00, 0x2B, 0x30, 0x30, 0x9D });
// Root directory
var rootPrefix = "/";
var root = Path.GetPathRoot(targetPath)!;
var rootIdData = Encoding.Default.GetBytes(rootPrefix + root)
.Concat(Enumerable.Repeat((byte)0, 21).ToArray())
.Concat(new byte[] { 0x00 }).ToArray();
bw.Write((short)(rootIdData.Length + 2));
bw.Write(rootIdData);
var targetLeafPrefix = fileInfo is not null && (fileInfo.Attributes & FileAttributes.Directory) != 0
? new byte[] { 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }
: new byte[] { 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
var targetLeaf = Path.GetRelativePath(root, targetPath);
var targetLeafIdData = targetLeafPrefix.Concat(Encoding.Default.GetBytes(targetLeaf)).Concat(new byte[] { 0x00 }).ToArray();
bw.Write((short)(targetLeafIdData.Length + 2));
bw.Write(targetLeafIdData);
var idListSize = (int)bw.BaseStream.Position - idListSizePos;
bw.Write((short)0);
// fix offsets
// IDListSize
bw.Seek(idListSizePos, SeekOrigin.Begin);
bw.Write((short)idListSize);
// restore pos
bw.Seek(idListSizePos + idListSize + 2, SeekOrigin.Begin);
}
void writeStringData()
{
// NAME
bw.Write((short)name.Length);
bw.Write(Encoding.Unicode.GetBytes(name));
// RELATIVE_PATH
var relativePath = Path.GetFileName(targetPath);
bw.Write((short)relativePath.Length);
bw.Write(Encoding.Unicode.GetBytes(relativePath));
// WORKING_DIR
var targetDir = Path.GetDirectoryName(targetPath)!;
bw.Write((short)targetDir.Length);
bw.Write(Encoding.Unicode.GetBytes(targetDir));
// ARGUMENTS
var cmdline = args is null ? "" : string.Join(
" ",
args.Select(
arg =>
{
if (string.IsNullOrEmpty(arg))
return arg;
string value = Regex.Replace(arg, @"(\\*)" + "\"", @"$1\$0");
value = Regex.Replace(value, @"^(.*\s.*?)(\\*)$", "\"$1$2$2\"");
return value;
}
).ToArray()
);
bw.Write((short)cmdline.Length);
bw.Write(Encoding.Unicode.GetBytes(cmdline));
}
writeShellLinkHeader();
writeLinkTargetIDList();
writeStringData();
}
/// <summary>
/// .NET Core compatible .lnk reader.
/// MS Documentation:
/// https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-shllink/16cb4ca1-9339-4d0c-a68d-bf1d6cc0f943?redirectedfrom=MSDN
/// </summary>
public static string? GetLnkTargetPath(string filepath)
{
using var br = new BinaryReader(File.OpenRead(filepath));
var headerSize = br.ReadUInt32();
if (headerSize != 0x4C)
{
throw new ApplicationException("Invalid LNK signature");
}
br.ReadBytes(0x10); // skip LinkCLSID
// LinkFlags
var linkFlags = br.ReadUInt32();
br.ReadBytes(4); // skip FileAttributes
br.ReadBytes(8); // skip CreationTime
br.ReadBytes(8); // skip AccessTime
br.ReadBytes(8); // skip WriteTime
br.ReadBytes(4); // skip FileSize
br.ReadBytes(4); // skip IconIndex
br.ReadBytes(4); // skip ShowCommand
br.ReadBytes(2); // skip Hotkey
br.ReadBytes(2); // skip Reserved
br.ReadBytes(4); // skip Reserved2
br.ReadBytes(4); // skip Reserved3
var hasLinkTargetIDList = (linkFlags & (1 << 0)) != 0;
var hasLinkInfo = (linkFlags & (1 << 1)) != 0;
var hasName = (linkFlags & (1 << 2)) != 0;
var hasRelativePath = (linkFlags & (1 << 3)) != 0;
var hasWorkingDir = (linkFlags & (1 << 4)) != 0;
var isUnicode = (linkFlags & (1 << 7)) != 0;
// if the HasLinkTargetIDList bit, skip LinkTargetIDList
if (hasLinkTargetIDList)
{
var skip = br.ReadUInt16();
br.ReadBytes(skip);
}
if (hasLinkInfo)
{
// get the number of bytes the path contains
var linkInfoSize = br.ReadUInt32();
br.ReadBytes(4); // skip LinkInfoHeaderSize
br.ReadBytes(4); // skip LinkInfoFlags
br.ReadBytes(4); // skip VolumeIDOffset
// Find the location of the LocalBasePath position
var localPathBaseOffset = br.ReadUInt32();
// Skip to the path position
// (subtract the length of the read (4 bytes), the length of the skip (12 bytes), and
// the length of the localPathBaseOffset read (4 bytes) from the localPathBaseOffset)
br.ReadBytes((int)localPathBaseOffset - 0x14);
var size = linkInfoSize - localPathBaseOffset - 0x02;
var bytePath = br.ReadBytes((int)size);
var path = Encoding.UTF8.GetString(bytePath, 0, bytePath.Length);
return path;
}
if (hasName)
{
var _ = isUnicode ? br.ReadSystemCodepageString() : br.ReadUtf16String(); // skip Name
}
string? relativePath = null;
if (hasRelativePath)
{
relativePath = isUnicode ? br.ReadSystemCodepageString() : br.ReadUtf16String();
}
string? workingDir = null;
if (hasWorkingDir)
{
workingDir = isUnicode ? br.ReadSystemCodepageString() : br.ReadUtf16String();
}
if (workingDir is not null && relativePath is not null)
{
return Path.Combine(workingDir, relativePath);
}
if (workingDir is not null)
{
return workingDir;
}
if (relativePath is not null)
{
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), relativePath);
}
throw new ApplicationException("Unable to determine link target path");
}
}