diff --git a/MainWindow.xaml b/MainWindow.xaml
index 5f0e9cd..e8c3712 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -175,7 +175,7 @@
@@ -206,6 +206,10 @@
+
@@ -331,6 +338,42 @@
Margin="8,0,0,0"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -378,11 +421,13 @@
+ Click="OnRefreshToken" Margin="0,0,8,0"
+ Visibility="{Binding IsJagexAccountSelected, Converter={StaticResource BoolToVisibility}}"/>
+ Click="OnShowTokenInfo"
+ Visibility="{Binding IsJagexAccountSelected, Converter={StaticResource BoolToVisibility}}"/>
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index cf75beb..efcc16f 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -23,6 +23,8 @@ public partial class MainWindow : Window, INotifyPropertyChanged
private readonly AuthService _authService = new();
private readonly LauncherService _launcherService = new();
private readonly JagexAccountService _jagexAccountService = new();
+ private readonly SteamAccountService _steamAccountService = new();
+ private readonly SteamLaunchService _steamLaunchService;
private readonly ProxInjectService _proxInjectService = new();
private readonly CharacterCreationQueueService _creationQueue;
@@ -41,6 +43,7 @@ public MainWindow()
LogLines.CollectionChanged += (_, _) => LogScrollViewer.ScrollToBottom();
_creationQueue = new CharacterCreationQueueService(_jagexAccountService);
+ _steamLaunchService = new SteamLaunchService(_steamAccountService);
_creationQueue.CharacterCreated += OnQueueCharacterCreated;
_creationQueue.BatchCompleted += OnQueueBatchCompleted;
_creationQueue.PendingCountChanged += OnQueueCountChanged;
@@ -72,6 +75,9 @@ public AccountProfile? SelectedAccount
_selectedAccount = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ProxyDisplayText));
+ OnPropertyChanged(nameof(AccountTypeText));
+ OnPropertyChanged(nameof(IsSteamAccountSelected));
+ OnPropertyChanged(nameof(IsJagexAccountSelected));
ApplyProxyToServices();
UpdateSelectedCharacters();
Settings.LastSelectedAccountId = _selectedAccount?.AccountId;
@@ -110,17 +116,30 @@ public GameAccount? SelectedCharacter
}
public string CharacterCountText =>
- $"{_selectedAccount?.GameAccounts.Count ?? 0} / 20";
+ _selectedAccount == null
+ ? "0 / 20"
+ : _selectedAccount.Provider == AccountProvider.Steam
+ ? "Steam profile"
+ : $"{_selectedAccount.GameAccounts.Count} / 20";
public bool CanCreateCharacter =>
- _selectedAccount != null && (_selectedAccount.GameAccounts.Count < 20);
+ _selectedAccount?.Provider == AccountProvider.Jagex && _selectedAccount.GameAccounts.Count < 20;
public bool CanAutoCreate =>
- _selectedAccount != null && _selectedAccount.GameAccounts.Count < 20;
+ _selectedAccount?.Provider == AccountProvider.Jagex && _selectedAccount.GameAccounts.Count < 20;
public string ProxyDisplayText =>
_selectedAccount?.Proxy?.ToDisplayString() ?? "None";
+ public string AccountTypeText =>
+ _selectedAccount?.ProviderLabel ?? "None";
+
+ public bool IsSteamAccountSelected =>
+ _selectedAccount?.Provider == AccountProvider.Steam;
+
+ public bool IsJagexAccountSelected =>
+ _selectedAccount?.Provider != AccountProvider.Steam;
+
public string QueueStatusText
{
get => _queueStatusText;
@@ -158,6 +177,24 @@ private async void OnLoaded(object sender, RoutedEventArgs e)
}
private async void OnAddAccount(object sender, RoutedEventArgs e)
+ {
+ var addDialog = new AddAccountDialog { Owner = this };
+ if (addDialog.ShowDialog() != true || addDialog.SelectedProvider == null)
+ {
+ StatusText = "Canceled";
+ return;
+ }
+
+ if (addDialog.SelectedProvider == AccountProvider.Steam)
+ {
+ await AddSteamAccountAsync();
+ return;
+ }
+
+ await AddJagexAccountAsync();
+ }
+
+ private async Task AddJagexAccountAsync()
{
try
{
@@ -216,6 +253,78 @@ private async void OnAddAccount(object sender, RoutedEventArgs e)
}
}
+ private async Task AddSteamAccountAsync()
+ {
+ try
+ {
+ StatusText = "Loading local Steam accounts...";
+ var localAccounts = _steamAccountService.GetLocalAccounts(Settings)
+ .Where(a => a.RememberPassword)
+ .ToList();
+
+ if (localAccounts.Count == 0)
+ {
+ MessageBox.Show(
+ "No remembered Steam accounts were found.\nEnable 'Remember me' in Steam, then retry.",
+ "Sigil");
+ StatusText = "Ready";
+ return;
+ }
+
+ var importDialog = new SteamImportDialog(localAccounts) { Owner = this };
+ if (importDialog.ShowDialog() != true || importDialog.SelectedAccount == null)
+ {
+ StatusText = "Canceled";
+ return;
+ }
+
+ var steamAccount = importDialog.SelectedAccount;
+ if (Accounts.Any(a =>
+ a.Provider == AccountProvider.Steam &&
+ string.Equals(a.SteamId64, steamAccount.SteamId64, StringComparison.OrdinalIgnoreCase)))
+ {
+ MessageBox.Show("This Steam account already exists.", "Sigil");
+ StatusText = "Ready";
+ return;
+ }
+
+ var characterDialog = new InputDialog("RuneScape character name for this Steam account:", "Steam Character")
+ {
+ Owner = this
+ };
+ characterDialog.ShowDialog();
+ var characterName = characterDialog.Response;
+ if (string.IsNullOrWhiteSpace(characterName))
+ {
+ StatusText = "Canceled";
+ return;
+ }
+
+ var profile = new AccountProfile
+ {
+ AccountId = $"steam:{steamAccount.SteamId64}",
+ Provider = AccountProvider.Steam,
+ DisplayName = string.IsNullOrWhiteSpace(steamAccount.PersonaName)
+ ? steamAccount.AccountName
+ : steamAccount.PersonaName,
+ SteamId64 = steamAccount.SteamId64,
+ SteamAccountName = steamAccount.AccountName,
+ SteamPersonaName = steamAccount.PersonaName,
+ SteamCharacterName = characterName.Trim()
+ };
+
+ Accounts.Add(profile);
+ await _accountStore.SaveAsync(Accounts);
+ SelectedAccount = profile;
+ StatusText = "Steam account imported";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Failed: {ex.Message}";
+ MessageBox.Show(ex.Message, "Sigil");
+ }
+ }
+
private async void OnRemoveAccount(object sender, RoutedEventArgs e)
{
if (SelectedAccount == null) return;
@@ -230,13 +339,21 @@ private async void OnRemoveAccount(object sender, RoutedEventArgs e)
Accounts.Remove(SelectedAccount);
SelectedAccount = Accounts.FirstOrDefault();
await _accountStore.SaveAsync(Accounts);
- await _tokenService.DeleteAsync(accountId);
+ if (!accountId.StartsWith("steam:", StringComparison.OrdinalIgnoreCase))
+ {
+ await _tokenService.DeleteAsync(accountId);
+ }
StatusText = "Account removed";
}
private async void OnRefreshToken(object sender, RoutedEventArgs e)
{
if (SelectedAccount == null) return;
+ if (SelectedAccount.Provider != AccountProvider.Jagex)
+ {
+ StatusText = "Steam accounts do not use Jagex tokens";
+ return;
+ }
try
{
@@ -270,6 +387,31 @@ private async void OnLaunch(object sender, RoutedEventArgs e)
try
{
StatusText = "Launching...";
+ if (SelectedAccount.Provider == AccountProvider.Steam)
+ {
+ var switched = await _steamLaunchService.EnsureActiveAccountAsync(
+ Settings,
+ SelectedAccount,
+ msg => Dispatcher.Invoke(() => AppendLog(msg)));
+
+ if (!switched)
+ {
+ var steamName = SelectedAccount.SteamAccountName ?? SelectedAccount.DisplayName;
+ StatusText = "Steam switch failed";
+ MessageBox.Show(
+ $"Steam could not switch to '{steamName}'.\nSwitch Steam manually, then retry launch.",
+ "Sigil");
+ return;
+ }
+
+ SelectedAccount.LastUsedAt = DateTimeOffset.UtcNow;
+ await _accountStore.SaveAsync(Accounts);
+ await _steamLaunchService.LaunchAsync(Settings, SelectedAccount, SelectedAccount.Proxy,
+ msg => Dispatcher.Invoke(() => AppendLog(msg)));
+ StatusText = "Game launched";
+ return;
+ }
+
var token = await _tokenService.LoadAsync(SelectedAccount.AccountId);
if (token == null)
@@ -365,6 +507,40 @@ private async void OnBrowseRs3Client(object sender, RoutedEventArgs e)
}
}
+ private async void OnBrowseSteamExe(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog
+ {
+ Filter = "Executable (*.exe)|*.exe",
+ Title = "Select Steam Executable"
+ };
+
+ if (dialog.ShowDialog(this) == true)
+ {
+ Settings.SteamExePath = dialog.FileName;
+ await _settingsStore.SaveAsync(Settings);
+ OnPropertyChanged(nameof(Settings));
+ StatusText = "Steam path updated";
+ }
+ }
+
+ private async void OnBrowseSteamRs3Client(object sender, RoutedEventArgs e)
+ {
+ var dialog = new OpenFileDialog
+ {
+ Filter = "Executable (*.exe)|*.exe",
+ Title = "Select Steam RuneScape Client"
+ };
+
+ if (dialog.ShowDialog(this) == true)
+ {
+ Settings.SteamRs3ClientPath = dialog.FileName;
+ await _settingsStore.SaveAsync(Settings);
+ OnPropertyChanged(nameof(Settings));
+ StatusText = "Steam RuneScape client path updated";
+ }
+ }
+
private async void OnSaveSettings(object sender, RoutedEventArgs e)
{
await _settingsStore.SaveAsync(Settings);
@@ -379,6 +555,12 @@ private async void OnShowTokenInfo(object sender, RoutedEventArgs e)
return;
}
+ if (SelectedAccount.Provider != AccountProvider.Jagex)
+ {
+ MessageBox.Show("Steam profiles do not store Jagex tokens.", "Token Info");
+ return;
+ }
+
var token = await _tokenService.LoadAsync(SelectedAccount.AccountId);
if (token == null)
{
@@ -410,6 +592,24 @@ private async Task UpdateStatusAsync()
return;
}
+ if (SelectedAccount.Provider == AccountProvider.Steam)
+ {
+ try
+ {
+ var current = _steamAccountService.GetCurrentAccount(Settings);
+ StatusText = current != null &&
+ string.Equals(current.AccountName, SelectedAccount.SteamAccountName, StringComparison.OrdinalIgnoreCase)
+ ? "Ready to launch via Steam"
+ : "Steam account mismatch. Launch will attempt to switch first.";
+ }
+ catch (Exception ex)
+ {
+ StatusText = $"Steam unavailable: {ex.Message}";
+ }
+
+ return;
+ }
+
var token = await _tokenService.LoadAsync(SelectedAccount.AccountId);
if (token == null)
{
@@ -424,6 +624,11 @@ private async Task UpdateStatusAsync()
private async Task LoadCharactersAsync(AccountProfile profile, OAuthToken token)
{
+ if (profile.Provider != AccountProvider.Jagex)
+ {
+ return;
+ }
+
if (string.IsNullOrWhiteSpace(token.SessionId)) return;
try
@@ -451,9 +656,23 @@ private void UpdateSelectedCharacters()
return;
}
- foreach (var account in SelectedAccount.GameAccounts)
+ if (SelectedAccount.Provider == AccountProvider.Steam)
+ {
+ if (!string.IsNullOrWhiteSpace(SelectedAccount.SteamCharacterName))
+ {
+ SelectedCharacters.Add(new GameAccount
+ {
+ AccountId = SelectedAccount.SteamId64 ?? SelectedAccount.AccountId,
+ DisplayName = SelectedAccount.SteamCharacterName
+ });
+ }
+ }
+ else
{
- SelectedCharacters.Add(account);
+ foreach (var account in SelectedAccount.GameAccounts)
+ {
+ SelectedCharacters.Add(account);
+ }
}
SelectedCharacter = SelectedCharacters.FirstOrDefault();
@@ -508,6 +727,11 @@ private void OnQueueStatus(string msg)
private async void OnAutoCreateCharacters(object sender, RoutedEventArgs e)
{
if (SelectedAccount == null) return;
+ if (SelectedAccount.Provider != AccountProvider.Jagex)
+ {
+ StatusText = "Character creation is only available for Jagex accounts";
+ return;
+ }
var token = await _tokenService.LoadAsync(SelectedAccount.AccountId);
if (token == null || string.IsNullOrWhiteSpace(token.RuneScapeSessionToken))
{
@@ -533,6 +757,12 @@ private async void OnRefreshCharacters(object sender, RoutedEventArgs e)
try
{
+ if (SelectedAccount.Provider != AccountProvider.Jagex)
+ {
+ StatusText = "Steam profiles do not support character refresh";
+ return;
+ }
+
var token = await _tokenService.LoadAsync(SelectedAccount.AccountId);
if (token == null)
{
@@ -562,7 +792,9 @@ private void ApplyProxyToServices()
{
var proxy = _selectedAccount?.Proxy;
_authService.SetProxy(proxy);
- _jagexAccountService.SetProxy(proxy);
+ _jagexAccountService.SetProxy((_selectedAccount?.Provider ?? AccountProvider.Jagex) == AccountProvider.Jagex
+ ? proxy
+ : null);
}
private async void OnConfigureProxy(object sender, RoutedEventArgs e)
diff --git a/Models/AccountProfile.cs b/Models/AccountProfile.cs
index 45f0075..15f8670 100644
--- a/Models/AccountProfile.cs
+++ b/Models/AccountProfile.cs
@@ -7,8 +7,16 @@ public sealed class AccountProfile
{
public string AccountId { get; set; } = Guid.NewGuid().ToString("N");
public string DisplayName { get; set; } = "New Account";
+ public AccountProvider Provider { get; set; } = AccountProvider.Jagex;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastUsedAt { get; set; }
public List GameAccounts { get; set; } = new();
public ProxyConfig? Proxy { get; set; }
+ public string? SteamId64 { get; set; }
+ public string? SteamAccountName { get; set; }
+ public string? SteamPersonaName { get; set; }
+ public string? SteamCharacterName { get; set; }
+
+ public string ProviderLabel => Provider == AccountProvider.Steam ? "Steam" : "Jagex";
+ public string ListDisplayName => $"{DisplayName} [{ProviderLabel}]";
}
diff --git a/Models/AccountProvider.cs b/Models/AccountProvider.cs
new file mode 100644
index 0000000..0d135ce
--- /dev/null
+++ b/Models/AccountProvider.cs
@@ -0,0 +1,7 @@
+namespace Sigil.Models;
+
+public enum AccountProvider
+{
+ Jagex,
+ Steam
+}
diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs
index d1ea74f..5b15d6c 100644
--- a/Models/AppSettings.cs
+++ b/Models/AppSettings.cs
@@ -7,6 +7,8 @@ public sealed class AppSettings
// Path to RS3 client executable - launched DIRECTLY with JX_SESSION_ID
public string? Rs3ClientPath { get; set; } = @"C:\ProgramData\Jagex\launcher\rs2client.exe";
+ public string? SteamExePath { get; set; }
+ public string? SteamRs3ClientPath { get; set; }
// How many characters to create per batch before waiting
public int CharacterCreationBatchSize { get; set; } = 3;
diff --git a/Models/SteamAccount.cs b/Models/SteamAccount.cs
new file mode 100644
index 0000000..4483716
--- /dev/null
+++ b/Models/SteamAccount.cs
@@ -0,0 +1,16 @@
+namespace Sigil.Models;
+
+public sealed class SteamAccount
+{
+ public string SteamId64 { get; set; } = string.Empty;
+ public string AccountName { get; set; } = string.Empty;
+ public string PersonaName { get; set; } = string.Empty;
+ public bool RememberPassword { get; set; }
+ public bool AllowAutoLogin { get; set; }
+ public bool IsMostRecent { get; set; }
+
+ public string DisplayLabel =>
+ string.IsNullOrWhiteSpace(PersonaName)
+ ? $"{AccountName} ({SteamId64})"
+ : $"{PersonaName} ({AccountName})";
+}
diff --git a/Services/LauncherService.cs b/Services/LauncherService.cs
index afbd9bc..6070748 100644
--- a/Services/LauncherService.cs
+++ b/Services/LauncherService.cs
@@ -33,6 +33,7 @@ public async Task LaunchAsync(AppSettings settings, OAuthToken? token,
var startInfo = new ProcessStartInfo
{
FileName = exePath,
+ WorkingDirectory = Path.GetDirectoryName(exePath) ?? string.Empty,
UseShellExecute = false
};
diff --git a/Services/SteamAccountService.cs b/Services/SteamAccountService.cs
new file mode 100644
index 0000000..179c386
--- /dev/null
+++ b/Services/SteamAccountService.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Microsoft.Win32;
+using Sigil.Models;
+
+namespace Sigil.Services;
+
+public sealed class SteamAccountService
+{
+ private const string RuneScapeAppName = "RuneScape";
+ private static readonly Regex UserBlockRegex = new(
+ "\"(?\\d{17})\"\\s*\\{(?.*?)\\n\\s*\\}",
+ RegexOptions.Singleline | RegexOptions.Compiled);
+
+ private static readonly Regex FieldRegex = new(
+ "\"(?[^\"]+)\"\\s*\"(?[^\"]*)\"",
+ RegexOptions.Compiled);
+
+ public IReadOnlyList GetLocalAccounts(AppSettings settings)
+ {
+ var loginUsersPath = Path.Combine(GetSteamRoot(settings), "config", "loginusers.vdf");
+ if (!File.Exists(loginUsersPath))
+ {
+ throw new FileNotFoundException($"Steam loginusers.vdf not found at: {loginUsersPath}");
+ }
+
+ var text = File.ReadAllText(loginUsersPath);
+ var accounts = new List();
+
+ foreach (Match match in UserBlockRegex.Matches(text))
+ {
+ var body = match.Groups["body"].Value;
+ var fields = FieldRegex.Matches(body)
+ .ToDictionary(m => m.Groups["key"].Value, m => m.Groups["value"].Value, StringComparer.OrdinalIgnoreCase);
+
+ var account = new SteamAccount
+ {
+ SteamId64 = match.Groups["id"].Value,
+ AccountName = GetField(fields, "AccountName"),
+ PersonaName = GetField(fields, "PersonaName"),
+ RememberPassword = GetField(fields, "RememberPassword") == "1",
+ AllowAutoLogin = GetField(fields, "AllowAutoLogin") == "1",
+ IsMostRecent = GetField(fields, "MostRecent") == "1"
+ };
+
+ if (string.IsNullOrWhiteSpace(account.AccountName))
+ {
+ continue;
+ }
+
+ accounts.Add(account);
+ }
+
+ return accounts
+ .OrderByDescending(a => a.IsMostRecent)
+ .ThenBy(a => a.PersonaName, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(a => a.AccountName, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public string GetSteamRoot(AppSettings settings)
+ {
+ if (!string.IsNullOrWhiteSpace(settings.SteamExePath))
+ {
+ var configuredDirectory = Path.GetDirectoryName(settings.SteamExePath);
+ if (!string.IsNullOrWhiteSpace(configuredDirectory) && Directory.Exists(configuredDirectory))
+ {
+ return configuredDirectory;
+ }
+ }
+
+ using var key = Registry.CurrentUser.OpenSubKey(@"Software\Valve\Steam");
+ var steamPath = key?.GetValue("SteamPath")?.ToString();
+ if (!string.IsNullOrWhiteSpace(steamPath) && Directory.Exists(steamPath))
+ {
+ return steamPath.Replace('/', Path.DirectorySeparatorChar);
+ }
+
+ throw new InvalidOperationException("Steam installation path could not be detected.");
+ }
+
+ public string GetSteamExePath(AppSettings settings)
+ {
+ if (!string.IsNullOrWhiteSpace(settings.SteamExePath) && File.Exists(settings.SteamExePath))
+ {
+ return settings.SteamExePath;
+ }
+
+ var path = Path.Combine(GetSteamRoot(settings), "steam.exe");
+ if (File.Exists(path))
+ {
+ return path;
+ }
+
+ throw new FileNotFoundException($"steam.exe not found at: {path}");
+ }
+
+ public string GetRuneScapeClientPath(AppSettings settings)
+ {
+ if (!string.IsNullOrWhiteSpace(settings.SteamRs3ClientPath) && File.Exists(settings.SteamRs3ClientPath))
+ {
+ return settings.SteamRs3ClientPath;
+ }
+
+ var steamRoot = GetSteamRoot(settings);
+ var libraryPaths = GetLibraryPaths(steamRoot);
+ foreach (var libraryPath in libraryPaths)
+ {
+ var candidate = Path.Combine(libraryPath, "steamapps", "common", "RuneScape", "launcher", "rs2client.exe");
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ }
+
+ throw new FileNotFoundException("Steam RuneScape client was not found in any Steam library.");
+ }
+
+ public string GetRuneScapeAppId(AppSettings settings)
+ {
+ foreach (var libraryPath in GetLibraryPaths(GetSteamRoot(settings)))
+ {
+ var manifestDirectory = Path.Combine(libraryPath, "steamapps");
+ if (!Directory.Exists(manifestDirectory))
+ {
+ continue;
+ }
+
+ foreach (var manifestPath in Directory.GetFiles(manifestDirectory, "appmanifest_*.acf"))
+ {
+ var text = File.ReadAllText(manifestPath);
+ if (!text.Contains($"\"name\"\t\t\"{RuneScapeAppName}\"", StringComparison.OrdinalIgnoreCase) &&
+ !Regex.IsMatch(text, "\"name\"\\s+\"RuneScape\"", RegexOptions.IgnoreCase) &&
+ !Regex.IsMatch(text, "\"installdir\"\\s+\"RuneScape\"", RegexOptions.IgnoreCase))
+ {
+ continue;
+ }
+
+ var match = Regex.Match(text, "\"appid\"\\s+\"(?\\d+)\"", RegexOptions.IgnoreCase);
+ if (match.Success)
+ {
+ return match.Groups["id"].Value;
+ }
+ }
+ }
+
+ throw new FileNotFoundException("Steam RuneScape appmanifest was not found in any Steam library.");
+ }
+
+ public SteamAccount? GetCurrentAccount(AppSettings settings)
+ {
+ var accounts = GetLocalAccounts(settings);
+
+ using var key = Registry.CurrentUser.OpenSubKey(@"Software\Valve\Steam");
+ var autoLoginUser = key?.GetValue("AutoLoginUser")?.ToString();
+ if (!string.IsNullOrWhiteSpace(autoLoginUser))
+ {
+ return accounts.FirstOrDefault(a =>
+ string.Equals(a.AccountName, autoLoginUser, StringComparison.OrdinalIgnoreCase));
+ }
+
+ return accounts.FirstOrDefault(a => a.IsMostRecent);
+ }
+
+ public bool CanAutoLogin(SteamAccount account)
+ {
+ return account.RememberPassword;
+ }
+
+ private static IReadOnlyList GetLibraryPaths(string steamRoot)
+ {
+ var paths = new List { steamRoot };
+ var libraryFoldersPath = Path.Combine(steamRoot, "steamapps", "libraryfolders.vdf");
+ if (!File.Exists(libraryFoldersPath))
+ {
+ return paths;
+ }
+
+ var text = File.ReadAllText(libraryFoldersPath);
+ foreach (Match match in Regex.Matches(text, "\"path\"\\s*\"(?[^\"]+)\"", RegexOptions.IgnoreCase))
+ {
+ var path = match.Groups["path"].Value.Replace(@"\\", @"\");
+ if (!string.IsNullOrWhiteSpace(path) &&
+ Directory.Exists(path) &&
+ !paths.Any(existing => string.Equals(existing, path, StringComparison.OrdinalIgnoreCase)))
+ {
+ paths.Add(path);
+ }
+ }
+
+ return paths;
+ }
+
+ private static string GetField(IReadOnlyDictionary fields, string key)
+ {
+ return fields.TryGetValue(key, out var value) ? value : string.Empty;
+ }
+}
diff --git a/Services/SteamLaunchService.cs b/Services/SteamLaunchService.cs
new file mode 100644
index 0000000..bc03832
--- /dev/null
+++ b/Services/SteamLaunchService.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Sigil.Models;
+
+namespace Sigil.Services;
+
+public sealed class SteamLaunchService
+{
+ private readonly SteamAccountService _steamAccountService;
+ private readonly ProxInjectService _proxInject = new();
+
+ public SteamLaunchService(SteamAccountService steamAccountService)
+ {
+ _steamAccountService = steamAccountService;
+ }
+
+ public async Task EnsureActiveAccountAsync(AppSettings settings, AccountProfile profile, Action? log = null)
+ {
+ var targetAccountName = profile.SteamAccountName;
+ if (string.IsNullOrWhiteSpace(targetAccountName))
+ {
+ throw new InvalidOperationException("Steam account name is missing for this profile.");
+ }
+
+ var current = _steamAccountService.GetCurrentAccount(settings);
+ if (current != null && string.Equals(current.AccountName, targetAccountName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ var candidates = _steamAccountService.GetLocalAccounts(settings);
+ var target = candidates.FirstOrDefault(a =>
+ string.Equals(a.AccountName, targetAccountName, StringComparison.OrdinalIgnoreCase));
+
+ if (target == null)
+ {
+ throw new InvalidOperationException($"Steam account '{targetAccountName}' was not found locally.");
+ }
+
+ if (!_steamAccountService.CanAutoLogin(target))
+ {
+ log?.Invoke($"Steam account '{target.AccountName}' is not remembered for auto-login.");
+ return false;
+ }
+
+ log?.Invoke($"Switching Steam to '{target.AccountName}'...");
+ await RestartSteamAsync(settings, target.AccountName).ConfigureAwait(false);
+
+ var verified = await WaitForAccountAsync(settings, target.AccountName).ConfigureAwait(false);
+ if (!verified)
+ {
+ log?.Invoke($"Steam did not switch to '{target.AccountName}'.");
+ }
+
+ return verified;
+ }
+
+ public Task LaunchAsync(AppSettings settings, AccountProfile profile, ProxyConfig? proxy = null, Action? log = null)
+ {
+ var steamExe = _steamAccountService.GetSteamExePath(settings);
+ var appId = _steamAccountService.GetRuneScapeAppId(settings);
+
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = steamExe,
+ WorkingDirectory = Path.GetDirectoryName(steamExe) ?? string.Empty,
+ Arguments = $"-applaunch {appId}",
+ UseShellExecute = true
+ };
+
+ var process = Process.Start(startInfo);
+ if (process == null)
+ {
+ throw new InvalidOperationException("Failed to launch RuneScape through Steam.");
+ }
+
+ if (proxy is { Enabled: true } && !string.IsNullOrWhiteSpace(proxy.Host) && ProxInjectService.IsInstalled)
+ {
+ log?.Invoke("Steam launches RuneScape via Steam bootstrap. Proxy injection is not applied on the initial Steam process.");
+ }
+ else if (proxy is { Enabled: true } && !ProxInjectService.IsInstalled)
+ {
+ log?.Invoke("Proxy configured but ProxInject not installed. Game traffic will NOT be proxied. Download it from Advanced Settings.");
+ }
+
+ return Task.FromResult(process);
+ }
+
+ private async Task RestartSteamAsync(AppSettings settings, string accountName)
+ {
+ var steamExe = _steamAccountService.GetSteamExePath(settings);
+
+ foreach (var process in Process.GetProcessesByName("steam"))
+ {
+ try
+ {
+ process.CloseMainWindow();
+ }
+ catch
+ {
+ // Fall back to shutdown command below.
+ }
+ }
+
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = steamExe,
+ Arguments = "-shutdown",
+ UseShellExecute = true
+ });
+ }
+ catch
+ {
+ // Ignore; Steam may already be closing.
+ }
+
+ var shutdownDeadline = DateTime.UtcNow.AddSeconds(20);
+ while (Process.GetProcessesByName("steam").Length > 0 && DateTime.UtcNow < shutdownDeadline)
+ {
+ await Task.Delay(500).ConfigureAwait(false);
+ }
+
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = steamExe,
+ Arguments = $"-login \"{accountName}\"",
+ UseShellExecute = true
+ });
+ }
+
+ private async Task WaitForAccountAsync(AppSettings settings, string accountName)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(30);
+ while (DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(1000).ConfigureAwait(false);
+ var current = _steamAccountService.GetCurrentAccount(settings);
+ if (current != null && string.Equals(current.AccountName, accountName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Views/AddAccountDialog.xaml b/Views/AddAccountDialog.xaml
new file mode 100644
index 0000000..2450a4a
--- /dev/null
+++ b/Views/AddAccountDialog.xaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/AddAccountDialog.xaml.cs b/Views/AddAccountDialog.xaml.cs
new file mode 100644
index 0000000..f785de4
--- /dev/null
+++ b/Views/AddAccountDialog.xaml.cs
@@ -0,0 +1,34 @@
+using System.Windows;
+using Sigil.Models;
+
+namespace Sigil.Views;
+
+public partial class AddAccountDialog : Window
+{
+ public AddAccountDialog()
+ {
+ InitializeComponent();
+ }
+
+ public AccountProvider? SelectedProvider { get; private set; }
+
+ private void OnJagex(object sender, RoutedEventArgs e)
+ {
+ SelectedProvider = AccountProvider.Jagex;
+ DialogResult = true;
+ Close();
+ }
+
+ private void OnSteam(object sender, RoutedEventArgs e)
+ {
+ SelectedProvider = AccountProvider.Steam;
+ DialogResult = true;
+ Close();
+ }
+
+ private void OnCancel(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+}
diff --git a/Views/SteamImportDialog.xaml b/Views/SteamImportDialog.xaml
new file mode 100644
index 0000000..7a30279
--- /dev/null
+++ b/Views/SteamImportDialog.xaml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/SteamImportDialog.xaml.cs b/Views/SteamImportDialog.xaml.cs
new file mode 100644
index 0000000..ffea1d8
--- /dev/null
+++ b/Views/SteamImportDialog.xaml.cs
@@ -0,0 +1,36 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Windows;
+using Sigil.Models;
+
+namespace Sigil.Views;
+
+public partial class SteamImportDialog : Window
+{
+ public SteamImportDialog(IEnumerable accounts)
+ {
+ InitializeComponent();
+ AccountsList.ItemsSource = accounts.ToList();
+ AccountsList.SelectedIndex = 0;
+ }
+
+ public SteamAccount? SelectedAccount => DialogResult == true ? AccountsList.SelectedItem as SteamAccount : null;
+
+ private void OnImport(object sender, RoutedEventArgs e)
+ {
+ if (AccountsList.SelectedItem == null)
+ {
+ MessageBox.Show("Select a Steam account to import.", "Sigil");
+ return;
+ }
+
+ DialogResult = true;
+ Close();
+ }
+
+ private void OnCancel(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ Close();
+ }
+}