mirror of
https://github.com/seanbetts/steam-hardware-watch.git
synced 2026-10-06 01:00:18 +02:00
Persist SteamKit auth sessions
This commit is contained in:
1 parent
692cef0abb
commit
fc0a08825d
6 files changed
+437
-50
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+38
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user