Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions ClassicAssist.Launcher/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
using System.Threading;
#region License

// Copyright (C) 2026 Reetus
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

#endregion

using System.Threading;
using System.Windows;
using CommandLine;
using Exceptionless;

namespace ClassicAssist.Launcher
Expand All @@ -9,11 +29,15 @@ namespace ClassicAssist.Launcher
/// </summary>
public partial class App : Application
{
public static CommandLineOptions CurrentOptions { get; set; } = new CommandLineOptions();

protected override void OnStartup( StartupEventArgs e )
{
ExceptionlessClient.Default.Configuration.DefaultData.Add( "Locale",
Thread.CurrentThread.CurrentUICulture.Name );
ExceptionlessClient.Default.Configuration.DefaultData.Add( "Locale", Thread.CurrentThread.CurrentUICulture.Name );
ExceptionlessClient.Default.Startup( "T8v0i7nL90cVRc4sr2pgo5hviThMPRF3OtQ0bK60" );

Parser.Default.ParseArguments<CommandLineOptions>( e.Args ).WithParsed( o => CurrentOptions = o );

base.OnStartup( e );
}
}
Expand Down
7 changes: 4 additions & 3 deletions ClassicAssist.Launcher/ClassicAssist.Launcher.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
<DebugType>pdbonly</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Exceptionless" Version="6.0.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="Exceptionless" Version="6.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.Windows.Interactivity.WPF" Version="2.0.20525" />
<PackageReference Include="Trinet.Core.IO.Ntfs" Version="4.1.1" />
<PackageReference Include="Trinet.Core.IO.Ntfs" Version="4.1.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClassicAssist.Controls\ClassicAssist.Controls.csproj" />
Expand Down
10 changes: 10 additions & 0 deletions ClassicAssist.Launcher/CommandLineOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using CommandLine;

namespace ClassicAssist.Launcher
{
public class CommandLineOptions
{
[Option( "shard", Required = false )]
public string Shard { get; set; }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalise Shard input to avoid false “not found” matches.

On Line 8, Shard is stored as-is, but downstream matching uses exact equality (e.Name == App.CurrentOptions.Shard in ClassicAssist.Launcher/MainViewModel.cs). A trailing/leading space in CLI input will fail matching unnecessarily.

Proposed fix
 public class CommandLineOptions
 {
+    private string _shard = string.Empty;
+
     [Option( "shard", Required = false )]
-    public string Shard { get; set; }
+    public string Shard
+    {
+        get => _shard;
+        set => _shard = value?.Trim() ?? string.Empty;
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ClassicAssist.Launcher/CommandLineOptions.cs` at line 8, The Shard property
currently stores input verbatim which can cause mismatches when MainViewModel.cs
compares e.Name == App.CurrentOptions.Shard; update the Shard property in
ClassicAssist.Launcher.CommandLineOptions to normalize input by trimming
whitespace (and optionally normalize case, e.g., ToLowerInvariant()) in its
setter and treat empty/whitespace-only values as null or empty string so
downstream exact equality comparisons won't fail due to leading/trailing spaces.

}
}
153 changes: 100 additions & 53 deletions ClassicAssist.Launcher/MainViewModel.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
using System;
#region License

// Copyright (C) 2026 Reetus
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

#endregion

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Configuration;
Expand All @@ -12,8 +31,10 @@
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Windows.Input;
using System.Windows.Shell;
using ClassicAssist.Launcher.Properties;
using ClassicAssist.Shared.Misc;
using ClassicAssist.Shared.Resources;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Trinet.Core.IO.Ntfs;
Expand Down Expand Up @@ -116,7 +137,8 @@ public MainViewModel()
HasStatusProtocol = token["HasStatusProtocol"]?.ToObject<bool>() ?? true,
Encryption = token["Encryption"]?.ToObject<bool>() ?? false,
Website = token["Website"]?.ToObject<string>(),
IsPreset = true
IsPreset = true,
LastPlayed = token["LastPlayed"]?.ToObject<DateTime>() ?? default
};

ShardManager.Shards.AddSorted( shard, new ShardEntryComparer() );
Expand All @@ -134,7 +156,8 @@ public MainViewModel()
Port = token["Port"]?.ToObject<int>() ?? 2593,
Website = token["Website"]?.ToObject<string>(),
HasStatusProtocol = token["HasStatusProtocol"]?.ToObject<bool>() ?? true,
Encryption = token["Encryption"]?.ToObject<bool>() ?? false
Encryption = token["Encryption"]?.ToObject<bool>() ?? false,
LastPlayed = token["LastPlayed"]?.ToObject<DateTime>() ?? default
};

ShardManager.Shards.AddSorted( shard, new ShardEntryComparer() );
Expand All @@ -145,10 +168,7 @@ public MainViewModel()
{
foreach ( JToken token in config["DeletedPresets"] )
{
ShardEntry shard = new ShardEntry
{
Name = token["Name"]?.ToObject<string>() ?? "Unknown", IsPreset = true
};
ShardEntry shard = new ShardEntry { Name = token["Name"]?.ToObject<string>() ?? "Unknown", IsPreset = true };

ShardEntry preset = ShardManager.Shards.FirstOrDefault( e => e.Equals( shard ) );

Expand All @@ -161,8 +181,7 @@ public MainViewModel()

if ( config["SelectedShard"] != null )
{
ShardEntry match = ShardManager.Shards.FirstOrDefault(
s => s.Name == config["SelectedShard"]?.ToObject<string>() );
ShardEntry match = ShardManager.Shards.FirstOrDefault( s => s.Name == config["SelectedShard"]?.ToObject<string>() );

if ( match != null )
{
Expand All @@ -185,11 +204,25 @@ public MainViewModel()
{
CheckPresets().ConfigureAwait( false );
}

if ( !string.IsNullOrEmpty( App.CurrentOptions.Shard ) )
{
ShardEntry shard = ShardManager.VisibleShards.FirstOrDefault( e => e.Name == App.CurrentOptions.Shard );

if ( shard == null )
{
MessageBox.Show( Resources.Shard_not_found, Strings.Error );
return;
}

SelectedShard = shard;

StartCommand.Execute( null );
}
Comment on lines +208 to +221
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Redundant null check and potential NullReferenceException.

Two issues here:

  1. NullReferenceException: App.CurrentOptions may be null if command-line parsing fails (see App.xaml.cs review). This access at line 203 would throw.

  2. Redundant code: The if ( shard != null ) check at line 213 is unreachable because if shard == null, the code returns at line 210.

🐛 Proposed fix
-                if ( !string.IsNullOrEmpty( App.CurrentOptions.Shard ) )
+                if ( App.CurrentOptions != null && !string.IsNullOrEmpty( App.CurrentOptions.Shard ) )
                 {
                     ShardEntry shard = ShardManager.VisibleShards.FirstOrDefault( e => e.Name == App.CurrentOptions.Shard );

                     if ( shard == null )
                     {
                         MessageBox.Show( @"Shard not found", Strings.Error );
                         return;
                     }

-                    if ( shard != null )
-                    {
-                        SelectedShard = shard;
-                    }
+                    SelectedShard = shard;

                     StartCommand.Execute( null );
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ( !string.IsNullOrEmpty( App.CurrentOptions.Shard ) )
{
ShardEntry shard = ShardManager.VisibleShards.FirstOrDefault( e => e.Name == App.CurrentOptions.Shard );
if ( shard == null )
{
MessageBox.Show( @"Shard not found", Strings.Error );
return;
}
if ( shard != null )
{
SelectedShard = shard;
}
StartCommand.Execute( null );
}
if ( App.CurrentOptions != null && !string.IsNullOrEmpty( App.CurrentOptions.Shard ) )
{
ShardEntry shard = ShardManager.VisibleShards.FirstOrDefault( e => e.Name == App.CurrentOptions.Shard );
if ( shard == null )
{
MessageBox.Show( @"Shard not found", Strings.Error );
return;
}
SelectedShard = shard;
StartCommand.Execute( null );
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ClassicAssist.Launcher/MainViewModel.cs` around lines 203 - 219, Guard
against App.CurrentOptions being null before accessing App.CurrentOptions.Shard
and remove the unreachable redundant null check: check App.CurrentOptions !=
null and !string.IsNullOrEmpty(App.CurrentOptions.Shard) before querying
ShardManager.VisibleShards, find the ShardEntry via
ShardManager.VisibleShards.FirstOrDefault(...), if shard is null show the
MessageBox and return, otherwise assign SelectedShard = shard and then call
StartCommand.Execute(null); remove the extra "if (shard != null)" branch since
the null case already returns.

}
}

public ICommand CheckForUpdateCommand =>
_checkforUpdateCommand ?? ( _checkforUpdateCommand = new RelayCommand( CheckForUpdate, UpdaterExists ) );
public ICommand CheckForUpdateCommand => _checkforUpdateCommand ?? ( _checkforUpdateCommand = new RelayCommand( CheckForUpdate, UpdaterExists ) );

public ClassicOptions ClassicOptions { get; set; } = new ClassicOptions();

Expand All @@ -199,25 +232,21 @@ public ObservableCollection<string> ClientPaths
set => SetProperty( ref _clientPaths, value );
}

public ICommand ClosingCommand =>
_closingCommand ?? ( _closingCommand = new RelayCommand( Closing, o => true ) );
public ICommand ClosingCommand => _closingCommand ?? ( _closingCommand = new RelayCommand( Closing, o => true ) );

public ObservableCollection<string> DataPaths
{
get => _dataPaths;
set => SetProperty( ref _dataPaths, value );
}

public ICommand OptionsCommand =>
_optionsCommand ?? ( _optionsCommand = new RelayCommand( ShowOptionsWindow, o => true ) );
public ICommand OptionsCommand => _optionsCommand ?? ( _optionsCommand = new RelayCommand( ShowOptionsWindow, o => true ) );

public List<PluginEntry> Plugins { get; set; } = new List<PluginEntry>();

public ICommand SelectClientPathCommand =>
_selectClientPathCommand ?? ( _selectClientPathCommand = new RelayCommand( SelectClientPath ) );
public ICommand SelectClientPathCommand => _selectClientPathCommand ?? ( _selectClientPathCommand = new RelayCommand( SelectClientPath ) );

public ICommand SelectDataPathCommand =>
_selectDataPathCommand ?? ( _selectDataPathCommand = new RelayCommand( SelectDataPath ) );
public ICommand SelectDataPathCommand => _selectDataPathCommand ?? ( _selectDataPathCommand = new RelayCommand( SelectDataPath ) );

public string SelectedClientPath
{
Expand All @@ -243,12 +272,10 @@ public ShardEntry SelectedShard

public string ShardsHash { get; set; } = string.Empty;

public ICommand ShowShardsWindowCommand =>
_showShardsWindowCommand ?? ( _showShardsWindowCommand = new RelayCommand( ShowShardsWindow, o => true ) );
public ICommand ShowShardsWindowCommand => _showShardsWindowCommand ?? ( _showShardsWindowCommand = new RelayCommand( ShowShardsWindow, o => true ) );

public ICommand StartCommand =>
_startCommand ?? ( _startCommand = new RelayCommandAsync( Start,
o => !string.IsNullOrEmpty( SelectedClientPath ) && !string.IsNullOrEmpty( SelectedDataPath ) ) );
_startCommand ?? ( _startCommand = new RelayCommandAsync( Start, o => !string.IsNullOrEmpty( SelectedClientPath ) && !string.IsNullOrEmpty( SelectedDataPath ) ) );

private async Task CheckPresets()
{
Expand Down Expand Up @@ -336,8 +363,7 @@ private static void RemoveAlternateDataStreams( string path )

string json = File.ReadAllText( manifestFile );

IEnumerable<ManifestEntry> manifestEntries =
JsonConvert.DeserializeObject<IEnumerable<ManifestEntry>>( json );
IEnumerable<ManifestEntry> manifestEntries = JsonConvert.DeserializeObject<IEnumerable<ManifestEntry>>( json );

if ( manifestEntries == null )
{
Expand All @@ -364,8 +390,7 @@ private static void RemoveAlternateDataStreams( string path )

private void ReadClassicOptions( JObject config )
{
PropertyInfo[] properties =
typeof( ClassicOptions ).GetProperties( BindingFlags.Public | BindingFlags.Instance );
PropertyInfo[] properties = typeof( ClassicOptions ).GetProperties( BindingFlags.Public | BindingFlags.Instance );

foreach ( PropertyInfo property in properties )
{
Expand Down Expand Up @@ -426,13 +451,7 @@ private static void CheckForUpdate( object obj )

private void SelectClientPath( object obj )
{
OpenFileDialog ofd = new OpenFileDialog
{
CheckFileExists = true,
Multiselect = false,
Filter = "ClassicUO.exe|ClassicUO.exe",
Title = Resources.Select_a_client
};
OpenFileDialog ofd = new OpenFileDialog { CheckFileExists = true, Multiselect = false, Filter = "ClassicUO.exe|ClassicUO.exe", Title = Resources.Select_a_client };

bool? result = ofd.ShowDialog();

Expand All @@ -451,10 +470,7 @@ private void SelectClientPath( object obj )

private void SelectDataPath( object obj )
{
FolderBrowserDialog folderBrowserDialog = new FolderBrowserDialog
{
Description = Resources.Select_your_Ultima_Online_directory, ShowNewFolderButton = false
};
FolderBrowserDialog folderBrowserDialog = new FolderBrowserDialog { Description = Resources.Select_your_Ultima_Online_directory, ShowNewFolderButton = false };
DialogResult result = folderBrowserDialog.ShowDialog();

if ( result != DialogResult.OK )
Expand Down Expand Up @@ -487,8 +503,7 @@ private async Task Start( object obj )

StringBuilder args = new StringBuilder();

List<string> pluginList =
new List<string> { Path.Combine( Environment.CurrentDirectory, "ClassicAssist.dll" ) };
List<string> pluginList = new List<string> { Path.Combine( Environment.CurrentDirectory, "ClassicAssist.dll" ) };

foreach ( PluginEntry plugin in Plugins )
{
Expand All @@ -507,25 +522,60 @@ private async Task Start( object obj )

ProcessStartInfo psi = new ProcessStartInfo
{
WorkingDirectory =
Path.GetDirectoryName( SelectedClientPath ) ?? throw new InvalidOperationException(),
WorkingDirectory = Path.GetDirectoryName( SelectedClientPath ) ?? throw new InvalidOperationException(),
FileName = SelectedClientPath,
Arguments = args.ToString(),
UseShellExecute = true
};

Process p = Process.Start( psi );

SelectedShard.LastPlayed = DateTime.Now;

UpdateJumpList( SelectedShard.Name );

if ( p != null && !p.HasExited )
{
Application.Current.Shutdown( 0 );
}
}

private void UpdateJumpList( string shardName )
{
ProcessModule processModule = Process.GetCurrentProcess().MainModule;

if ( processModule == null )
{
return;
}

string fileName = processModule.FileName;
string directory = Path.GetDirectoryName( fileName );

JumpList jumpList = new JumpList { ShowRecentCategory = true };

IOrderedEnumerable<ShardEntry> playedShards = ShardManager.VisibleShards.Where( e => e.LastPlayed != default ).OrderBy( e => e.LastPlayed );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

OrderBy sorts ascending — most recent shards will appear last.

For a "recently played" list, users typically expect the most recently played items first. OrderBy sorts ascending, so DateTime.MinValue (never played) appears first and the most recent appears last.

🔧 Proposed fix
-            IOrderedEnumerable<ShardEntry> playedShards = ShardManager.VisibleShards.Where( e => e.LastPlayed != default ).OrderBy( e => e.LastPlayed );
+            IOrderedEnumerable<ShardEntry> playedShards = ShardManager.VisibleShards.Where( e => e.LastPlayed != default ).OrderByDescending( e => e.LastPlayed );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ClassicAssist.Launcher/MainViewModel.cs` at line 562, The code builds
playedShards from ShardManager.VisibleShards but uses OrderBy(e => e.LastPlayed)
which sorts ascending so oldest/never-played appear first; change the sort to
OrderByDescending(e => e.LastPlayed) (keeping the existing Where(e =>
e.LastPlayed != default)) so ShardEntry items with the most recent LastPlayed
appear first.


foreach ( ShardEntry shard in playedShards )
{
jumpList.JumpItems.Add( new JumpTask
{
Title = shard.Name,
Arguments = $"--shard \"{shard.Name}\"",
ApplicationPath = fileName,
IconResourcePath = fileName,
WorkingDirectory = directory,
CustomCategory = "Shards"
} );
}

JumpList.SetJumpList( Application.Current, jumpList );
jumpList.Apply();
}

private void BuildClassicOptions( StringBuilder args )
{
PropertyInfo[] properties =
typeof( ClassicOptions ).GetProperties( BindingFlags.Public | BindingFlags.Instance );
PropertyInfo[] properties = typeof( ClassicOptions ).GetProperties( BindingFlags.Public | BindingFlags.Instance );

foreach ( PropertyInfo property in properties )
{
Expand All @@ -537,7 +587,7 @@ private void BuildClassicOptions( StringBuilder args )
continue;
}

bool skip = val is bool b && b == false && !attr.IncludeIfFalse;
bool skip = val is bool b && !b && !attr.IncludeIfFalse;
bool canInclude = true;

if ( !string.IsNullOrEmpty( attr.CanIncludeProperty ) )
Expand All @@ -562,8 +612,7 @@ private void ShowShardsWindow( object obj )
ShardsWindow window = new ShardsWindow();
window.ShowDialog();

if ( !( window.DataContext is ShardsViewModel vm ) || vm.DialogResult != DialogResult.OK ||
vm.SelectedShard == null )
if ( !( window.DataContext is ShardsViewModel vm ) || vm.DialogResult != DialogResult.OK || vm.SelectedShard == null )
{
return;
}
Expand Down Expand Up @@ -657,7 +706,8 @@ private void Closing( object obj )
{ "Port", shard.Port },
{ "HasStatusProtocol", shard.HasStatusProtocol },
{ "Website", shard.Website },
{ "Encryption", shard.Encryption }
{ "Encryption", shard.Encryption },
{ "LastPlayed", shard.LastPlayed }
};

presetsArray.Add( shardObj );
Expand All @@ -668,9 +718,7 @@ private void Closing( object obj )

WriteClassicOptions( config );

using ( JsonTextWriter jtw =
new JsonTextWriter(
new StreamWriter( Path.Combine( Environment.CurrentDirectory, CONFIG_FILENAME ) ) ) )
using ( JsonTextWriter jtw = new JsonTextWriter( new StreamWriter( Path.Combine( Environment.CurrentDirectory, CONFIG_FILENAME ) ) ) )
{
jtw.Formatting = Formatting.Indented;
config.WriteTo( jtw );
Expand All @@ -679,8 +727,7 @@ private void Closing( object obj )

private void WriteClassicOptions( JObject config )
{
PropertyInfo[] properties =
typeof( ClassicOptions ).GetProperties( BindingFlags.Public | BindingFlags.Instance );
PropertyInfo[] properties = typeof( ClassicOptions ).GetProperties( BindingFlags.Public | BindingFlags.Instance );

foreach ( PropertyInfo property in properties )
{
Expand Down
Loading
Loading