Skip to content
Open
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
239 changes: 239 additions & 0 deletions Core/SteamLoginCdp.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
using System;
using System.IO;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;

namespace SAM.Core
{
public enum CdpLoginResult
{
Success,
NeedCode,
NoDebugPort,
Failed
}

// Drives Steam's CEF login page over the Chrome DevTools Protocol.
// Replaces the old UI Automation approach
public static class SteamLoginCdp
{
private const string Endpoint = "http://127.0.0.1:8080";
private const string FlagFileName = ".cef-enable-remote-debugging";

public static void EnsureRemoteDebugging(string steamPath)
{
try
{
if (string.IsNullOrEmpty(steamPath)) return;
string flag = Path.Combine(steamPath, FlagFileName);
if (!File.Exists(flag)) File.Create(flag).Dispose();
}
catch (Exception e) { Console.WriteLine("EnsureRemoteDebugging: " + e.Message); }
}

public static CdpLoginResult Login(string user, string password, string sharedSecret, int timeoutSeconds = 120)
{
try { return LoginAsync(user, password, sharedSecret, timeoutSeconds).GetAwaiter().GetResult(); }
catch (Exception e) { Console.WriteLine("CDP Login: " + e.Message); return CdpLoginResult.Failed; }
}

private static async Task<CdpLoginResult> LoginAsync(string user, string password, string sharedSecret, int timeoutSeconds)
{
string ws = await WaitForLoginTarget(30);
if (ws == null) return CdpLoginResult.NoDebugPort;

using (var cdp = new Cdp())
{
await cdp.Connect(ws);

DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
if (WindowUtils.GetMainSteamClientWindow("Steam").IsValid)
return CdpLoginResult.Success;

string state = await SafeEval(cdp, JsState);
if (state == null)
{
// Endpoint dropped during the post-login handoff; the client window is the source of truth.
if (WindowUtils.GetMainSteamClientWindow("Steam").IsValid) return CdpLoginResult.Success;
await Task.Delay(500);
continue;
}

switch (state)
{
case "login":
string fill = await SafeEval(cdp, JsFill, JsStr(user), JsStr(password));
await Task.Delay(fill == "submitted" ? 1500 : 700);
break;

case "selection":
await SafeEval(cdp, JsSelect);
await Task.Delay(700);
break;

case "code":
if (string.IsNullOrEmpty(sharedSecret)) return CdpLoginResult.NeedCode;
string code = WindowUtils.Generate2FACode(sharedSecret);
await SafeEval(cdp, JsCode, JsStr(code));
await Task.Delay(2500);
break;

case "loading":
await Task.Delay(800);
break;

default:
await Task.Delay(600);
break;
}
}
}

return WindowUtils.GetMainSteamClientWindow("Steam").IsValid ? CdpLoginResult.Success : CdpLoginResult.Failed;
}

private static async Task<string> SafeEval(Cdp cdp, string fn, params string[] args)
{
try { return await cdp.Eval(fn, args); } catch { return null; }
}

private static async Task<string> WaitForLoginTarget(int seconds)
{
DateTime deadline = DateTime.UtcNow.AddSeconds(seconds);
while (DateTime.UtcNow < deadline)
{
try
{
string json = HttpGet(Endpoint + "/json");
foreach (Match m in Regex.Matches(json, "\\{[^{}]*?\"webSocketDebuggerUrl\"[^{}]*?\\}", RegexOptions.Singleline))
{
string blk = m.Value;
if (Regex.IsMatch(blk, "\"type\"\\s*:\\s*\"page\"") &&
blk.IndexOf("Sign in to Steam", StringComparison.OrdinalIgnoreCase) >= 0)
return Regex.Match(blk, "\"webSocketDebuggerUrl\"\\s*:\\s*\"([^\"]*)\"").Groups[1].Value;
}
}
catch { }
await Task.Delay(500);
}
return null;
}

private static string HttpGet(string url)
{
var req = (HttpWebRequest)WebRequest.Create(url);
req.Timeout = 4000;
using (var resp = (HttpWebResponse)req.GetResponse())
using (var sr = new StreamReader(resp.GetResponseStream()))
return sr.ReadToEnd();
}

private static string JsStr(string s)
{
var sb = new StringBuilder("\"");
foreach (char c in s)
{
if (c == '"' || c == '\\') sb.Append('\\').Append(c);
else if (c == '\n') sb.Append("\\n");
else if (c == '\r') sb.Append("\\r");
else sb.Append(c);
}
return sb.Append('"').ToString();
}

// ---- injected page scripts (shadow-piercing, React-safe) ----

private const string JsFill = @"function(u,p){
var a=[];(function d(r){var e=r.querySelectorAll('*');for(var i=0;i<e.length;i++){a.push(e[i]);if(e[i].shadowRoot)d(e[i].shadowRoot);}})(document);
var ui=null,pi=null,btn=null;
for(var i=0;i<a.length;i++){var el=a[i];
if(el.tagName==='INPUT'){if(el.type==='password')pi=el;else if(!ui&&(el.type==='text'||el.type===''||el.type==='email'))ui=el;}
if(el.tagName==='BUTTON'&&(el.textContent||'').toLowerCase().indexOf('sign in')>=0)btn=el;}
if(!ui||!pi)return 'no-fields';
var s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
function set(el,v){el.focus();s.call(el,v);el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));}
set(ui,u);set(pi,p);
if(!btn)return 'no-button';
if(btn.disabled)return 'disabled';
btn.click();return 'submitted';}";

private const string JsState = @"function(){
var a=[];(function d(r){var e=r.querySelectorAll('*');for(var i=0;i<e.length;i++){a.push(e[i]);if(e[i].shadowRoot)d(e[i].shadowRoot);}})(document);
var pw=0,txt=0;
for(var i=0;i<a.length;i++){var el=a[i];if(el.tagName==='INPUT'){if(el.type==='password')pw++;else if(el.type==='text'||el.type==='')txt++;}}
var t=((document.body&&document.body.innerText)||'').toLowerCase();
if(t.indexOf('loading user data')>=0)return 'loading';
if(pw>0)return 'login';
if(t.indexOf('enter the code')>=0||t.indexOf('authenticator')>=0||txt>=5||(txt>0&&t.indexOf('code')>=0))return 'code';
if(t.indexOf('sign in as')>=0||t.indexOf('add an account')>=0)return 'selection';
return 'other';}";

private const string JsCode = @"function(c){
var a=[];(function d(r){var e=r.querySelectorAll('*');for(var i=0;i<e.length;i++){a.push(e[i]);if(e[i].shadowRoot)d(e[i].shadowRoot);}})(document);
var ins=[];for(var i=0;i<a.length;i++){var el=a[i];if(el.tagName==='INPUT'&&el.type!=='hidden'&&el.type!=='password')ins.push(el);}
var s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
function set(el,v){el.focus();s.call(el,v);el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));}
if(ins.length>=c.length){for(var i=0;i<c.length;i++)set(ins[i],c[i]);return 'code-multi';}
if(ins.length===1){set(ins[0],c);return 'code-single';}
return 'code-none';}";

private const string JsSelect = @"function(){
var a=[];(function d(r){var e=r.querySelectorAll('*');for(var i=0;i<e.length;i++){a.push(e[i]);if(e[i].shadowRoot)d(e[i].shadowRoot);}})(document);
for(var i=0;i<a.length;i++){var el=a[i];var t=(el.textContent||'').toLowerCase();
if((el.tagName==='BUTTON'||el.getAttribute('role')==='button')&&(t.indexOf('add an account')>=0||t.indexOf('different account')>=0)){el.click();return 'picked';}}
return 'no-add';}";

// ---- minimal CDP transport ----
private class Cdp : IDisposable
{
private readonly ClientWebSocket _sock = new ClientWebSocket();
private int _id;

public async Task Connect(string ws)
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
await _sock.ConnectAsync(new Uri(ws), cts.Token);
}

public async Task<string> Eval(string funcJs, params string[] jsonArgs)
{
string expr = "(" + funcJs + ")(" + string.Join(",", jsonArgs) + ")";
string p = "{\"expression\":" + JsStr(expr) + ",\"returnByValue\":true,\"awaitPromise\":true}";
string reply = await Call("Runtime.evaluate", p);
if (reply == null) return null;
Match m = Regex.Match(reply, "\"value\"\\s*:\\s*\"([^\"\\\\]*)\"");
return m.Success ? m.Groups[1].Value : null;
}

private async Task<string> Call(string method, string paramsJson)
{
int id = ++_id;
string msg = "{\"id\":" + id + ",\"method\":\"" + method + "\",\"params\":" + paramsJson + "}";
await _sock.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(msg)), WebSocketMessageType.Text, true, CancellationToken.None);

var buf = new byte[262144];
for (int i = 0; i < 60; i++)
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
using (var ms = new MemoryStream())
{
WebSocketReceiveResult r;
do { r = await _sock.ReceiveAsync(new ArraySegment<byte>(buf), cts.Token); ms.Write(buf, 0, r.Count); }
while (!r.EndOfMessage);
string reply = Encoding.UTF8.GetString(ms.ToArray());
if (Regex.IsMatch(reply, "\"id\"\\s*:\\s*" + id + "\\b")) return reply;
}
}
return null;
}

public void Dispose() { try { _sock.Dispose(); } catch { } }
}
}
}
1 change: 1 addition & 0 deletions SAM.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
<Compile Include="Core\LoginWindowState.cs" />
<Compile Include="Core\SortType.cs" />
<Compile Include="Core\SteamExitCode.cs" />
<Compile Include="Core\SteamLoginCdp.cs" />
<Compile Include="Core\UpdateResponse.cs" />
<Compile Include="Core\WindowUtils.cs" />
<Compile Include="Views\AccountInfoDialog.xaml.cs">
Expand Down
45 changes: 44 additions & 1 deletion Views/AccountsWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,9 @@ private void Login(Account account, int tryCount)

string startParams = parametersBuilder.ToString();

// Enable Steam's CEF remote debugging before launch so we can drive the login page over CDP.
SteamLoginCdp.EnsureRemoteDebugging(settings.User.SteamPath);

// Start Steam process with the selected path.
ProcessStartInfo startInfo = new ProcessStartInfo
{
Expand All @@ -1355,7 +1358,47 @@ private void Login(Account account, int tryCount)
return;
}

EnterCredentials(steamProcess, account, 0);
LoginViaCdp(account, tryCount);
}

private void LoginViaCdp(Account account, int tryCount)
{
string password = StringCipher.Decrypt(account.Password, eKey);
string secret = StringCipher.Decrypt(account.SharedSecret, eKey);

SetWindowTitle("Working");

CdpLoginResult result = SteamLoginCdp.Login(account.Name, password, secret);

switch (result)
{
case CdpLoginResult.Success:
PostLogin();
break;

case CdpLoginResult.NeedCode:
MessageBox.Show("This account requires a Steam Guard code, but no shared secret is set for it.\n" +
"Add the account's shared secret, or enter the code manually in Steam.",
"Steam Guard", MessageBoxButton.OK, MessageBoxImage.Warning);
break;

case CdpLoginResult.NoDebugPort:
MessageBox.Show("Could not connect to Steam's sign-in page (CEF remote debugging).\n\n" +
"Steam may need to be fully closed and reopened once so it picks up the debugging flag.",
"Login", MessageBoxButton.OK, MessageBoxImage.Error);
break;

default:
if (tryCount + 1 < maxRetry)
{
Login(account, tryCount + 1);
}
else
{
MessageBox.Show("Login Failed! Please try again.", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
break;
}
}

private void EnterCredentials(Process steamProcess, Account account, int tryCount)
Expand Down