Persist SteamKit auth sessions

This commit is contained in:
seanbetts committed 2026-05-11 11:44:57 +01:00
1 parent 692cef0abb
commit fc0a08825d
6 files changed
+437 -50

No files matched your search

+15 -3
View File
@@ -144,13 +144,23 @@ mkdir -p .local
cat > .local/steamkit-env.sh <<'EOF'
STEAMKIT_USERNAME='your-watch-account'
STEAMKIT_PASSWORD='your-watch-account-password'
# Use one of these only when Steam Guard asks for it:
# Use one of these only for the one-time session bootstrap when Steam Guard asks for it:
# STEAMKIT_AUTH_CODE='email-code'
# STEAMKIT_TWO_FACTOR_CODE='authenticator-code'
# Or approve the mobile prompt manually, then set:
# STEAMKIT_ACCEPT_MOBILE_CONFIRMATION='1'
EOF
chmod 600 .local/steamkit-env.sh
```
Bootstrap a persistent local session:
```sh
scripts/steamkit_auth.sh
```
This writes `.local/steamkit-session.json` with a Steam refresh token and guard data. The file is gitignored, should stay local, and is sensitive. After it is created, remove any one-time `STEAMKIT_AUTH_CODE`, `STEAMKIT_TWO_FACTOR_CODE`, or `STEAMKIT_ACCEPT_MOBILE_CONFIRMATION` line from `.local/steamkit-env.sh`.
Then run either the source directly:
```sh
@@ -163,6 +173,8 @@ or the normal watcher:
scripts/run_watch.sh 2026-05-11
```
The normal watcher automatically uses `.local/steamkit-session.json`, so routine runs should not ask for a fresh Steam Guard code. If Steam invalidates the refresh token, rerun `scripts/steamkit_auth.sh` with a fresh guard approval to create a new session file.
Outputs:
- `RUN_DIR/api/steamkit/pics-product-info.json`
@@ -171,7 +183,7 @@ Outputs:
- `RUN_DIR/reports/steamkit-pics-detail.md`
- `RUN_DIR/reports/steamkit-pics-errors.txt`
If the env file is missing, the script writes a non-fatal `missing_credentials` report so the normal watcher still completes. Do not use your main Steam account, do not poll aggressively, and do not extend this helper to protected depot downloads.
If the env file and session file are missing, the script writes a non-fatal `missing_credentials` report so the normal watcher still completes. Do not use your main Steam account, do not poll aggressively, and do not extend this helper to protected depot downloads.
## Output
@@ -429,7 +441,7 @@ SteamVR content is distributed through SteamPipe depots rather than the SteamOS
## Current Known Limits
- `Komodo` can block both `curl` and fresh browser automation.
- `SteamKit/PICS` requires a separate Steam account and may need a fresh Steam Guard code on first login.
- `SteamKit/PICS` requires a separate Steam account and a one-time Steam Guard bootstrap to create `.local/steamkit-session.json`.
- `SteamDB` can return a Cloudflare browser challenge to curl and fresh automated browser contexts.
- `SteamVR` depot contents require a Steam install, Steam console, SteamCMD, or another depot downloader; the helper does not download large depots by default.
- The SteamOS mirror is public, but package names are not proof of product launch state without corroborating evidence.
+10 -1
View File
@@ -136,13 +136,22 @@ Treat SteamKit/PICS as the primary direct Steam metadata source for apps, packag
Use a narrow SteamKit2 helper when available. It should:
- use a separate low-risk Steam account, not the user's main account
- load credentials from `.local/steamkit-env.sh` or equivalent environment variables
- bootstrap a persistent session with `scripts/steamkit_auth.sh` before normal runs
- prefer `.local/steamkit-session.json` for routine checks so Steam Guard is not required every run
- load bootstrap credentials from `.local/steamkit-env.sh` or equivalent environment variables only when a new session is needed
- poll only watched Valve hardware app and package IDs
- save raw app/package product-info snapshots and changenumbers
- write `steamkit-pics-packages.tsv`, `steamkit-pics-key-lines.txt`, and `steamkit-pics-detail.md`
- compare snapshots against the previous run
- avoid protected depot downloads and aggressive polling
SteamKit auth handling:
- keep `.local/steamkit-env.sh` and `.local/steamkit-session.json` local and uncommitted
- use `STEAMKIT_AUTH_CODE`, `STEAMKIT_TWO_FACTOR_CODE`, or `STEAMKIT_ACCEPT_MOBILE_CONFIRMATION=1` only for the bootstrap command
- remove one-time guard values from `.local/steamkit-env.sh` after `scripts/steamkit_auth.sh` succeeds
- if routine checks fail because the refresh token expired or was revoked, ask the user to re-run `scripts/steamkit_auth.sh` with a fresh Steam Guard approval
Primary watched IDs:
- apps: Controller `4165870`, Frame `4165890`, Machine `4165910`
+9 -1
View File
@@ -12,6 +12,7 @@ REPORT_DIR="$RUN_DIR/reports"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
PROJECT_DIR="$REPO_DIR/tools/steamkit-pics"
SESSION_FILE="${STEAMKIT_SESSION_FILE:-$REPO_DIR/.local/steamkit-session.json}"
mkdir -p "$OUT_DIR" "$REPORT_DIR"
ERROR_FILE="$REPORT_DIR/steamkit-pics-errors.txt"
@@ -84,7 +85,12 @@ write_unavailable_report() {
load_local_env
if [ -z "${STEAMKIT_USERNAME:-}" ] || { [ -z "${STEAMKIT_PASSWORD:-}" ] && [ -z "${STEAMKIT_ACCESS_TOKEN:-}" ]; }; then
HAS_SESSION=0
if [ -s "$SESSION_FILE" ]; then
HAS_SESSION=1
fi
if [ "$HAS_SESSION" = "0" ] && { [ -z "${STEAMKIT_USERNAME:-}" ] || { [ -z "${STEAMKIT_PASSWORD:-}" ] && [ -z "${STEAMKIT_ACCESS_TOKEN:-}" ]; }; }; then
detail="STEAMKIT_USERNAME/STEAMKIT_PASSWORD not set"
if [ -n "${STEAMKIT_USERNAME:-}" ] && [ -z "${STEAMKIT_PASSWORD:-}" ]; then
detail="STEAMKIT_PASSWORD or STEAMKIT_ACCESS_TOKEN not set"
@@ -135,6 +141,7 @@ if [ -n "$PREVIOUS_REPORT" ]; then
--report "$REPORT_FILE" \
--key-lines "$KEY_LINES_FILE" \
--markdown-report "$DETAIL_REPORT_FILE" \
--session-file "$SESSION_FILE" \
--previous-report "$PREVIOUS_REPORT" \
>> "$REPORT_DIR/steamkit-pics-dotnet.log" 2>> "$ERROR_FILE"; then
detail="SteamKit/PICS helper failed; see steamkit-pics-errors.txt and steamkit-pics-dotnet.log"
@@ -149,6 +156,7 @@ else
--report "$REPORT_FILE" \
--key-lines "$KEY_LINES_FILE" \
--markdown-report "$DETAIL_REPORT_FILE" \
--session-file "$SESSION_FILE" \
>> "$REPORT_DIR/steamkit-pics-dotnet.log" 2>> "$ERROR_FILE"; then
detail="SteamKit/PICS helper failed; see steamkit-pics-errors.txt and steamkit-pics-dotnet.log"
if [ ! -s "$REPORT_FILE" ]; then
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname "$0")" && pwd)"
REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)"
PROJECT_DIR="$REPO_DIR/tools/steamkit-pics"
ENV_FILE="${STEAMKIT_ENV_FILE:-$REPO_DIR/.local/steamkit-env.sh}"
SESSION_FILE="${STEAMKIT_SESSION_FILE:-$REPO_DIR/.local/steamkit-session.json}"
if [ -f "$ENV_FILE" ]; then
# shellcheck disable=SC1090
. "$ENV_FILE"
fi
if [ -z "${STEAMKIT_USERNAME:-}" ] || [ -z "${STEAMKIT_PASSWORD:-}" ]; then
echo "STEAMKIT_USERNAME and STEAMKIT_PASSWORD are required in $ENV_FILE or the environment" >&2
exit 1
fi
export STEAMKIT_USERNAME STEAMKIT_PASSWORD
if [ -n "${STEAMKIT_AUTH_CODE:-}" ]; then
export STEAMKIT_AUTH_CODE
fi
if [ -n "${STEAMKIT_TWO_FACTOR_CODE:-}" ]; then
export STEAMKIT_TWO_FACTOR_CODE
fi
if [ -n "${STEAMKIT_ACCEPT_MOBILE_CONFIRMATION:-}" ]; then
export STEAMKIT_ACCEPT_MOBILE_CONFIRMATION
fi
if [ -n "${STEAMKIT_TIMEOUT_SECONDS:-}" ]; then
export STEAMKIT_TIMEOUT_SECONDS
fi
mkdir -p "$(dirname "$SESSION_FILE")"
dotnet run --project "$PROJECT_DIR" -- --auth-session-out "$SESSION_FILE" --session-file "$SESSION_FILE"
chmod 600 "$SESSION_FILE"
printf '%s\n' "Saved SteamKit session to $SESSION_FILE"
+151
View File
@@ -16,6 +16,93 @@ def write_executable(path, content):
class CheckSteamKitPicsTests(unittest.TestCase):
def test_invokes_dotnet_helper_with_persistent_session_without_password(self):
tmp = TemporaryDirectory()
self.addCleanup(tmp.cleanup)
tmp_path = Path(tmp.name)
run_dir = tmp_path / "run"
session_file = tmp_path / "steamkit-session.json"
session_file.write_text(
'{"username":"watcher","refresh_token":"refresh-token","created_at_utc":"2026-05-11T00:00:00Z"}\n',
encoding="utf-8",
)
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
log_path = tmp_path / "dotnet-args.log"
write_executable(
fake_bin / "dotnet",
"""
#!/bin/sh
printf '%s\\n' "$*" > "$FAKE_DOTNET_LOG"
report=""
key_lines=""
markdown=""
out_dir=""
session=""
while [ "$#" -gt 0 ]; do
case "$1" in
--session-file)
shift
session="$1"
;;
--out-dir)
shift
out_dir="$1"
;;
--report)
shift
report="$1"
;;
--key-lines)
shift
key_lines="$1"
;;
--markdown-report)
shift
markdown="$1"
;;
esac
shift || true
done
test -s "$session" || exit 12
mkdir -p "$out_dir" "$(dirname "$report")" "$(dirname "$key_lines")" "$(dirname "$markdown")"
printf '%s\\n' '{}' > "$out_dir/pics-product-info.json"
printf '%s\\n' 'type product id status changenumber previous_changenumber changed_since_previous sha_hash only_public name related_ids details' > "$report"
printf '%s\\n' 'SteamKit/PICS package snapshot:' > "$key_lines"
printf '%s\\n' '# SteamKit / PICS Detail' > "$markdown"
""",
)
env = os.environ.copy()
env.update(
{
"PATH": f"{fake_bin}:{env['PATH']}",
"STEAMKIT_ENV_FILE": str(tmp_path / "missing-env.sh"),
"STEAMKIT_SESSION_FILE": str(session_file),
"FAKE_DOTNET_LOG": str(log_path),
}
)
env.pop("STEAMKIT_USERNAME", None)
env.pop("STEAMKIT_PASSWORD", None)
result = subprocess.run(
[str(ROOT / "scripts" / "check_steamkit_pics.sh"), str(run_dir)],
cwd=ROOT,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
dotnet_args = log_path.read_text(encoding="utf-8")
self.assertIn("--session-file", dotnet_args)
self.assertIn(str(session_file), dotnet_args)
self.assertNotIn("missing_credentials", (run_dir / "reports" / "steamkit-pics-packages.tsv").read_text(encoding="utf-8"))
def test_missing_credentials_writes_nonfatal_unavailable_report(self):
tmp = TemporaryDirectory()
self.addCleanup(tmp.cleanup)
@@ -341,6 +428,70 @@ class CheckSteamKitPicsTests(unittest.TestCase):
self.assertEqual("", errors)
class SteamKitAuthScriptTests(unittest.TestCase):
def test_auth_script_writes_session_file_using_dotnet_helper(self):
tmp = TemporaryDirectory()
self.addCleanup(tmp.cleanup)
tmp_path = Path(tmp.name)
env_file = tmp_path / "steamkit-env.sh"
session_file = tmp_path / "steamkit-session.json"
env_file.write_text(
"STEAMKIT_USERNAME='watcher'\n"
"STEAMKIT_PASSWORD='secret'\n"
"STEAMKIT_AUTH_CODE='ABCDE'\n",
encoding="utf-8",
)
fake_bin = tmp_path / "bin"
fake_bin.mkdir()
log_path = tmp_path / "dotnet-args.log"
write_executable(
fake_bin / "dotnet",
"""
#!/bin/sh
printf '%s\\n' "$*" > "$FAKE_DOTNET_LOG"
out=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--auth-session-out" ]; then
shift
out="$1"
fi
shift || true
done
mkdir -p "$(dirname "$out")"
printf '%s\\n' '{"username":"watcher","refresh_token":"refresh","created_at_utc":"2026-05-11T00:00:00Z"}' > "$out"
""",
)
env = os.environ.copy()
env.update(
{
"PATH": f"{fake_bin}:{env['PATH']}",
"STEAMKIT_ENV_FILE": str(env_file),
"STEAMKIT_SESSION_FILE": str(session_file),
"FAKE_DOTNET_LOG": str(log_path),
}
)
result = subprocess.run(
[str(ROOT / "scripts" / "steamkit_auth.sh")],
cwd=ROOT,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
self.assertEqual("", result.stderr)
self.assertEqual(0, result.returncode)
self.assertTrue(session_file.exists())
dotnet_args = log_path.read_text(encoding="utf-8")
self.assertIn("--auth-session-out", dotnet_args)
self.assertIn(str(session_file), dotnet_args)
self.assertIn("--session-file", dotnet_args)
class SteamKitSummaryTests(unittest.TestCase):
def test_status_draft_and_run_summary_include_steamkit_outputs(self):
tmp = TemporaryDirectory()
+214 -45
View File
@@ -1,38 +1,74 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using SteamKit2;
using SteamKit2.Authentication;
using SteamKit2.Internal;
var options = Args.Parse(args);
Directory.CreateDirectory(options.OutDir);
Directory.CreateDirectory(Path.GetDirectoryName(options.ReportPath) ?? ".");
Directory.CreateDirectory(Path.GetDirectoryName(options.KeyLinesPath) ?? ".");
Directory.CreateDirectory(Path.GetDirectoryName(options.MarkdownReportPath) ?? ".");
var username = Env("STEAMKIT_USERNAME");
var password = Env("STEAMKIT_PASSWORD");
var accessToken = Env("STEAMKIT_ACCESS_TOKEN");
var authCode = Env("STEAMKIT_AUTH_CODE");
var twoFactorCode = Env("STEAMKIT_TWO_FACTOR_CODE");
var acceptMobileConfirmation = Env("STEAMKIT_ACCEPT_MOBILE_CONFIRMATION") == "1";
var timeoutSeconds = int.TryParse(Env("STEAMKIT_TIMEOUT_SECONDS"), out var parsedTimeout)
? parsedTimeout
: 90;
if (string.IsNullOrWhiteSpace(username) || (string.IsNullOrWhiteSpace(password) && string.IsNullOrWhiteSpace(accessToken)))
{
throw new InvalidOperationException("STEAMKIT_USERNAME and STEAMKIT_PASSWORD or STEAMKIT_ACCESS_TOKEN are required.");
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
var snapshot = await PicsClient.FetchAsync(username, password, accessToken, authCode, twoFactorCode, cts.Token);
var jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
if (!string.IsNullOrWhiteSpace(options.AuthSessionOutPath))
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
throw new InvalidOperationException("STEAMKIT_USERNAME and STEAMKIT_PASSWORD are required for auth bootstrap.");
}
var existingSession = SteamKitSession.Load(options.SessionFilePath);
var session = await AuthClient.CreateSessionAsync(
username,
password,
authCode,
twoFactorCode,
acceptMobileConfirmation,
existingSession?.GuardData,
cts.Token
);
Directory.CreateDirectory(Path.GetDirectoryName(options.AuthSessionOutPath) ?? ".");
await File.WriteAllTextAsync(options.AuthSessionOutPath, JsonSerializer.Serialize(session, jsonOptions) + Environment.NewLine, cts.Token);
return;
}
options.RequireWatchMode();
Directory.CreateDirectory(options.OutDir!);
Directory.CreateDirectory(Path.GetDirectoryName(options.ReportPath!) ?? ".");
Directory.CreateDirectory(Path.GetDirectoryName(options.KeyLinesPath!) ?? ".");
Directory.CreateDirectory(Path.GetDirectoryName(options.MarkdownReportPath!) ?? ".");
var savedSession = SteamKitSession.Load(options.SessionFilePath);
if (savedSession is not null)
{
username = savedSession.Username;
password = null;
accessToken = savedSession.RefreshToken;
authCode = null;
twoFactorCode = null;
}
if (string.IsNullOrWhiteSpace(username) || (string.IsNullOrWhiteSpace(password) && string.IsNullOrWhiteSpace(accessToken)))
{
throw new InvalidOperationException("STEAMKIT_USERNAME and STEAMKIT_PASSWORD, STEAMKIT_ACCESS_TOKEN, or a valid --session-file are required.");
}
var snapshot = await PicsClient.FetchAsync(username, password, accessToken, authCode, twoFactorCode, cts.Token);
await File.WriteAllTextAsync(
Path.Combine(options.OutDir, "pics-product-info.json"),
Path.Combine(options.OutDir!, "pics-product-info.json"),
JsonSerializer.Serialize(snapshot, jsonOptions) + Environment.NewLine,
cts.Token
);
@@ -40,7 +76,7 @@ await File.WriteAllTextAsync(
var previous = PreviousRows.Load(options.PreviousReportPath);
var rows = ReportRows.Build(snapshot, previous);
await File.WriteAllLinesAsync(
options.ReportPath,
options.ReportPath!,
new[] { ReportRow.HeaderLine() }.Concat(rows.Select(row => row.ToTsv())),
cts.Token
);
@@ -48,13 +84,20 @@ await File.WriteAllLinesAsync(
var keyLines = new List<string> { "SteamKit/PICS package snapshot:" };
keyLines.Add(ReportRow.HeaderLine());
keyLines.AddRange(rows.Take(40).Select(row => row.ToTsv()));
await File.WriteAllLinesAsync(options.KeyLinesPath, keyLines, cts.Token);
await File.WriteAllLinesAsync(options.KeyLinesPath!, keyLines, cts.Token);
await File.WriteAllTextAsync(options.MarkdownReportPath, MarkdownReport.Build(snapshot, rows, options.PreviousReportPath), cts.Token);
await File.WriteAllTextAsync(options.MarkdownReportPath!, MarkdownReport.Build(snapshot, rows, options.PreviousReportPath), cts.Token);
static string? Env(string name) => Environment.GetEnvironmentVariable(name);
sealed record Args(string OutDir, string ReportPath, string KeyLinesPath, string MarkdownReportPath, string? PreviousReportPath)
sealed record Args(
string? OutDir,
string? ReportPath,
string? KeyLinesPath,
string? MarkdownReportPath,
string? PreviousReportPath,
string? SessionFilePath,
string? AuthSessionOutPath)
{
public static Args Parse(string[] args)
{
@@ -75,20 +118,33 @@ sealed record Args(string OutDir, string ReportPath, string KeyLinesPath, string
}
return new Args(
Require(values, "--out-dir"),
Require(values, "--report"),
Require(values, "--key-lines"),
Require(values, "--markdown-report"),
Optional(values, "--out-dir"),
Optional(values, "--report"),
Optional(values, "--key-lines"),
Optional(values, "--markdown-report"),
values.TryGetValue("--previous-report", out var previousReport) && !string.IsNullOrWhiteSpace(previousReport)
? previousReport
: null
: null,
Optional(values, "--session-file"),
Optional(values, "--auth-session-out")
);
}
static string Require(Dictionary<string, string> values, string key)
public void RequireWatchMode()
{
_ = Require(OutDir, "--out-dir");
_ = Require(ReportPath, "--report");
_ = Require(KeyLinesPath, "--key-lines");
_ = Require(MarkdownReportPath, "--markdown-report");
}
static string? Optional(Dictionary<string, string> values, string key)
=> values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: throw new ArgumentException($"{key} is required.");
: null;
static string Require(string? value, string key)
=> !string.IsNullOrWhiteSpace(value) ? value : throw new ArgumentException($"{key} is required.");
}
static class Targets
@@ -112,6 +168,137 @@ static class Targets
};
}
sealed record SteamKitSession(
[property: JsonPropertyName("username")] string Username,
[property: JsonPropertyName("steam_id")] string? SteamId,
[property: JsonPropertyName("refresh_token")] string RefreshToken,
[property: JsonPropertyName("access_token")] string? AccessToken,
[property: JsonPropertyName("guard_data")] string? GuardData,
[property: JsonPropertyName("created_at_utc")] DateTimeOffset CreatedAtUtc)
{
public static SteamKitSession? Load(string? path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
return null;
}
var session = JsonSerializer.Deserialize<SteamKitSession>(File.ReadAllText(path));
return string.IsNullOrWhiteSpace(session?.RefreshToken) ? null : session;
}
}
static class AuthClient
{
public static async Task<SteamKitSession> CreateSessionAsync(
string username,
string password,
string? emailCode,
string? deviceCode,
bool acceptMobileConfirmation,
string? guardData,
CancellationToken cancellationToken)
{
var steamClient = new SteamClient();
var manager = new CallbackManager(steamClient);
var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var disconnected = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
manager.Subscribe<SteamClient.ConnectedCallback>(_ => connected.TrySetResult());
manager.Subscribe<SteamClient.DisconnectedCallback>(callback =>
{
if (!callback.UserInitiated)
{
disconnected.TrySetResult("Steam disconnected before auth completed.");
}
});
steamClient.Connect();
await CallbackPump.UntilAsync(manager, connected.Task, disconnected.Task, cancellationToken);
var authTask = steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails
{
Username = username,
Password = password,
DeviceFriendlyName = "steam-hardware-watch",
PlatformType = EAuthTokenPlatformType.k_EAuthTokenPlatformType_SteamClient,
WebsiteID = "Client",
IsPersistentSession = true,
GuardData = guardData,
Authenticator = new EnvAuthenticator(emailCode, deviceCode, acceptMobileConfirmation),
});
await CallbackPump.UntilAsync(manager, authTask, disconnected.Task, cancellationToken);
var authSession = await authTask;
var pollTask = authSession.PollingWaitForResultAsync(cancellationToken);
await CallbackPump.UntilAsync(manager, pollTask, disconnected.Task, cancellationToken);
var result = await pollTask;
steamClient.Disconnect();
if (string.IsNullOrWhiteSpace(result.RefreshToken))
{
throw new InvalidOperationException("Steam auth completed without a refresh token.");
}
return new SteamKitSession(
result.AccountName,
authSession is CredentialsAuthSession credentials ? credentials.SteamID.ToString() : null,
result.RefreshToken,
result.AccessToken,
result.NewGuardData,
DateTimeOffset.UtcNow
);
}
}
sealed class EnvAuthenticator(string? emailCode, string? deviceCode, bool acceptMobileConfirmation) : IAuthenticator
{
public Task<string> GetDeviceCodeAsync(bool previousCodeWasIncorrect)
{
if (!string.IsNullOrWhiteSpace(deviceCode) && !previousCodeWasIncorrect)
{
return Task.FromResult(deviceCode);
}
throw new InvalidOperationException("Steam mobile authenticator code required. Set STEAMKIT_TWO_FACTOR_CODE and rerun scripts/steamkit_auth.sh.");
}
public Task<string> GetEmailCodeAsync(string email, bool previousCodeWasIncorrect)
{
if (!string.IsNullOrWhiteSpace(emailCode) && !previousCodeWasIncorrect)
{
return Task.FromResult(emailCode);
}
throw new InvalidOperationException($"Steam Guard email code required for {email}. Set STEAMKIT_AUTH_CODE and rerun scripts/steamkit_auth.sh.");
}
public Task<bool> AcceptDeviceConfirmationAsync() => Task.FromResult(acceptMobileConfirmation);
}
static class CallbackPump
{
public static async Task UntilAsync(
CallbackManager manager,
Task primary,
Task<string> disconnected,
CancellationToken cancellationToken)
{
while (!primary.IsCompleted)
{
if (disconnected.IsCompleted)
{
throw new InvalidOperationException(await disconnected);
}
await manager.RunWaitCallbackAsync(cancellationToken);
}
await primary;
}
}
static class PicsClient
{
public static async Task<PicsSnapshot> FetchAsync(
@@ -138,7 +325,7 @@ static class PicsClient
AccessToken = accessToken,
AuthCode = authCode,
TwoFactorCode = twoFactorCode,
ShouldRememberPassword = false,
ShouldRememberPassword = !string.IsNullOrWhiteSpace(accessToken),
});
});
@@ -167,7 +354,7 @@ static class PicsClient
});
steamClient.Connect();
await PumpUntilAsync(manager, login.Task, disconnected.Task, cancellationToken);
await CallbackPump.UntilAsync(manager, login.Task, disconnected.Task, cancellationToken);
var aggregate = new ProductAggregate();
var productInfo = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -184,7 +371,7 @@ static class PicsClient
}
});
await PumpUntilAsync(manager, productInfo.Task, disconnected.Task, cancellationToken);
await CallbackPump.UntilAsync(manager, productInfo.Task, disconnected.Task, cancellationToken);
steamUser.LogOff();
steamClient.Disconnect();
@@ -192,24 +379,6 @@ static class PicsClient
return aggregate.ToSnapshot();
}
static async Task PumpUntilAsync(
CallbackManager manager,
Task primary,
Task<string> disconnected,
CancellationToken cancellationToken)
{
while (!primary.IsCompleted)
{
if (disconnected.IsCompleted)
{
throw new InvalidOperationException(await disconnected);
}
await manager.RunWaitCallbackAsync(cancellationToken);
}
await primary;
}
}
sealed class ProductAggregate