From 9a66ff4f4855cf17fe89a9c3daeb46de8afa2f2c Mon Sep 17 00:00:00 2001 From: seanbetts Date: Mon, 11 May 2026 09:11:13 +0100 Subject: [PATCH] Add SteamKit PICS watcher --- .gitignore | 2 + README.md | 50 ++- SKILL.md | 5 +- references/sources.md | 7 + scripts/check_steamkit_pics.sh | 114 ++++++ scripts/compare_runs.py | 1 + scripts/draft_status_update.py | 10 + scripts/run_watch.sh | 1 + scripts/write_run_summary.py | 3 + tests/test_check_steamkit_pics.py | 296 ++++++++++++++ tools/steamkit-pics/Program.cs | 381 +++++++++++++++++++ tools/steamkit-pics/SteamHardwarePics.csproj | 12 + 12 files changed, 880 insertions(+), 2 deletions(-) create mode 100755 scripts/check_steamkit_pics.sh create mode 100644 tests/test_check_steamkit_pics.py create mode 100644 tools/steamkit-pics/Program.cs create mode 100644 tools/steamkit-pics/SteamHardwarePics.csproj diff --git a/.gitignore b/.gitignore index e226dc7..4f36912 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ __pycache__/ *.pyc .venv/ node_modules/ +bin/ +obj/ .worktrees/ # Local agent/browser state diff --git a/README.md b/README.md index 6e26696..8812cd5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Agent-oriented monitoring workflow for rumored or upcoming Valve hardware, focus It is designed to be run by a coding agent through the included `SKILL.md`, with scripts that: -- check `Komodo`, `SteamDB`, `SteamTracking`, and Valve endpoints +- check `Komodo`, `SteamKit/PICS`, `SteamDB`, `SteamTracking`, and Valve endpoints - check `SteamVR` depot metadata and the public SteamOS package mirror - save raw artifacts for each run - compare against the previous run @@ -65,6 +65,7 @@ Required: Recommended: +- `.NET SDK 10` for the SteamKit/PICS helper - `playwright-cli` - Google Chrome or Chromium available to Playwright - macOS `open` command with Google Chrome installed for the Komodo background bootstrap helper @@ -118,6 +119,51 @@ scripts/close_steamdb.sh If both sources are blocked, bootstrap both before the run. +## Optional SteamKit/PICS Account Setup + +SteamKit/PICS is the primary direct Steam metadata source for watched app and package movement. It requires a low-risk Steam account because the helper logs into Steam like a normal client and requests metadata only. + +Install requirement: + +```sh +dotnet restore tools/steamkit-pics/SteamHardwarePics.csproj +``` + +Create a local-only env file: + +```sh +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: +# STEAMKIT_AUTH_CODE='email-code' +# STEAMKIT_TWO_FACTOR_CODE='authenticator-code' +EOF +chmod 600 .local/steamkit-env.sh +``` + +Then run either the source directly: + +```sh +scripts/check_steamkit_pics.sh ~/steam_hardware_watch/2026-05-11 +``` + +or the normal watcher: + +```sh +scripts/run_watch.sh 2026-05-11 ~/steam_hardware_watch /tmp/SteamTracking-master +``` + +Outputs: + +- `RUN_DIR/api/steamkit/pics-product-info.json` +- `RUN_DIR/reports/steamkit-pics-packages.tsv` +- `RUN_DIR/reports/steamkit-pics-key-lines.txt` +- `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. + ## Output Each run writes: @@ -132,6 +178,7 @@ Each run writes: - `retrieved-visual-assets.tsv` - `blocked-visual-assets.tsv` - `manual-asset-urls.txt` +- `steamkit-pics-packages.tsv` - `steamvr-depots-key-lines.txt` - `steamos-mirror-key-lines.txt` @@ -372,6 +419,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. - `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. diff --git a/SKILL.md b/SKILL.md index 6ba3cb6..2fbd565 100644 --- a/SKILL.md +++ b/SKILL.md @@ -47,6 +47,7 @@ Use the source helpers when available: - [scripts/run_watch.sh](scripts/run_watch.sh) - [scripts/check_komodo.sh](scripts/check_komodo.sh) +- [scripts/check_steamkit_pics.sh](scripts/check_steamkit_pics.sh) - [scripts/check_steamdb.sh](scripts/check_steamdb.sh) - [scripts/check_steamtracking.sh](scripts/check_steamtracking.sh) - [scripts/check_steamvr_depots.sh](scripts/check_steamvr_depots.sh) @@ -133,8 +134,10 @@ 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 - poll only watched Valve hardware app and package IDs - save raw app/package product-info snapshots and changenumbers +- write `steamkit-pics-packages.tsv` and `steamkit-pics-key-lines.txt` - compare snapshots against the previous run - avoid protected depot downloads and aggressive polling @@ -349,7 +352,7 @@ Use [scripts/run_watch.sh](scripts/run_watch.sh) for the normal path. It will: 1. initialize the run folder -2. run the Komodo, SteamDB, SteamTracking, SteamVR depot, SteamOS mirror, and Valve endpoint checks +2. run the Komodo, SteamKit/PICS, SteamDB, SteamTracking, SteamVR depot, SteamOS mirror, and Valve endpoint checks 3. auto-detect the previous run folder when possible 4. write a comparison report into the current run folder 5. save discovered visual assets into the current run folder diff --git a/references/sources.md b/references/sources.md index 6d61c5f..c0ad8e7 100644 --- a/references/sources.md +++ b/references/sources.md @@ -53,6 +53,13 @@ For each normal run, a SteamKit/PICS helper should snapshot: - depot and branch metadata exposed in app product info, when present - raw JSON or normalized structured output suitable for diffing against the previous run +Local helper: + +- `scripts/check_steamkit_pics.sh` +- raw snapshot: `RUN_DIR/api/steamkit/pics-product-info.json` +- normalized package/app report: `RUN_DIR/reports/steamkit-pics-packages.tsv` +- summary lines: `RUN_DIR/reports/steamkit-pics-key-lines.txt` + Operational constraints: - use a separate Steam account, not the user's main account diff --git a/scripts/check_steamkit_pics.sh b/scripts/check_steamkit_pics.sh new file mode 100755 index 0000000..ed7dec9 --- /dev/null +++ b/scripts/check_steamkit_pics.sh @@ -0,0 +1,114 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 1 ]; then + echo "usage: $0 RUN_DIR" >&2 + exit 1 +fi + +RUN_DIR="$1" +OUT_DIR="$RUN_DIR/api/steamkit" +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" +mkdir -p "$OUT_DIR" "$REPORT_DIR" + +ERROR_FILE="$REPORT_DIR/steamkit-pics-errors.txt" +REPORT_FILE="$REPORT_DIR/steamkit-pics-packages.tsv" +KEY_LINES_FILE="$REPORT_DIR/steamkit-pics-key-lines.txt" +: > "$ERROR_FILE" +: > "$REPORT_FILE" +: > "$KEY_LINES_FILE" + +load_local_env() { + env_file="${STEAMKIT_ENV_FILE:-$REPO_DIR/.local/steamkit-env.sh}" + if [ -f "$env_file" ]; then + # shellcheck disable=SC1090 + . "$env_file" + fi +} + +write_unavailable_report() { + reason="$1" + detail="$2" + { + printf '%s\n' "type product id status changenumber related_ids details" + printf '%s\n' "app Steam Controller 4165870 $reason $detail" + printf '%s\n' "app Steam Frame 4165890 $reason $detail" + printf '%s\n' "app Steam Machine 4165910 $reason $detail" + printf '%s\n' "package Steam Controller 1558609 $reason $detail" + printf '%s\n' "package Steam Machine 1629446 $reason $detail" + printf '%s\n' "package Steam Machine 1629447 $reason $detail" + printf '%s\n' "package Steam Machine 1629458 $reason $detail" + printf '%s\n' "package Steam Machine 1629460 $reason $detail" + printf '%s\n' "package Steam Frame 1629484 $reason $detail" + printf '%s\n' "package Steam Frame 1629486 $reason $detail" + } > "$REPORT_FILE" + + { + printf '%s\n' "SteamKit/PICS unavailable: $detail" + printf '%s\n' "" + printf '%s\n' "SteamKit/PICS package snapshot:" + sed -n '1,40p' "$REPORT_FILE" + } > "$KEY_LINES_FILE" +} + +load_local_env + +if [ -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" + fi + write_unavailable_report "missing_credentials" "$detail" + printf '%s\n' "$detail" >> "$ERROR_FILE" + printf '%s\n' \ + "SteamKit/PICS unavailable: $detail" \ + "Saved SteamKit/PICS report to $REPORT_FILE" + exit 0 +fi + +export STEAMKIT_USERNAME +if [ -n "${STEAMKIT_PASSWORD:-}" ]; then + export STEAMKIT_PASSWORD +fi +if [ -n "${STEAMKIT_ACCESS_TOKEN:-}" ]; then + export STEAMKIT_ACCESS_TOKEN +fi +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_TIMEOUT_SECONDS:-}" ]; then + export STEAMKIT_TIMEOUT_SECONDS +fi + +if ! command -v dotnet >/dev/null 2>&1; then + detail="dotnet CLI not found" + write_unavailable_report "missing_dotnet" "$detail" + printf '%s\n' "$detail" >> "$ERROR_FILE" + printf '%s\n' \ + "SteamKit/PICS unavailable: $detail" \ + "Saved SteamKit/PICS report to $REPORT_FILE" + exit 0 +fi + +if ! dotnet run --project "$PROJECT_DIR" -- \ + --out-dir "$OUT_DIR" \ + --report "$REPORT_FILE" \ + --key-lines "$KEY_LINES_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 + write_unavailable_report "helper_failed" "$detail" + fi + printf '%s\n' "$detail" >> "$ERROR_FILE" +fi + +printf '%s\n' \ + "Saved SteamKit/PICS snapshots to $OUT_DIR" \ + "Saved matching lines to $KEY_LINES_FILE" \ + "Saved SteamKit/PICS report to $REPORT_FILE" diff --git a/scripts/compare_runs.py b/scripts/compare_runs.py index 21826eb..aaa2801 100755 --- a/scripts/compare_runs.py +++ b/scripts/compare_runs.py @@ -122,6 +122,7 @@ def build_summary(prev_dir: Path, curr_dir: Path): deltas.extend(compare_report_sets(prev_dir, curr_dir, "Komodo controller media", "komodo-controller-media-interesting.tsv")) deltas.extend(compare_report_sets(prev_dir, curr_dir, "Komodo machine media", "komodo-machine-media.tsv")) deltas.extend(compare_report_sets(prev_dir, curr_dir, "Komodo frame media", "komodo-frame-media.tsv")) + deltas.extend(compare_text_lines(prev_dir, curr_dir, "SteamKit/PICS", "steamkit-pics-key-lines.txt")) deltas.extend(compare_text_lines(prev_dir, curr_dir, "SteamDB", "steamdb-key-lines.txt")) deltas.extend(compare_text_lines(prev_dir, curr_dir, "SteamVR depots", "steamvr-depots-key-lines.txt")) deltas.extend(compare_text_lines(prev_dir, curr_dir, "SteamOS mirror", "steamos-mirror-key-lines.txt")) diff --git a/scripts/draft_status_update.py b/scripts/draft_status_update.py index b579779..05d611a 100755 --- a/scripts/draft_status_update.py +++ b/scripts/draft_status_update.py @@ -46,6 +46,7 @@ def build_draft(run_dir: Path): compare_lines = [line for line in read_lines(compare_file) if line.startswith("- ")][:12] komodo_mods = first_n_nonempty(reports / "komodo-product-modified.tsv", 10) + steamkit_lines = first_n_nonempty(reports / "steamkit-pics-key-lines.txt", 10) steamdb_lines = first_n_nonempty(reports / "steamdb-key-lines.txt", 8) valve_lines = first_n_nonempty(reports / "valve-key-lines.txt", 8) tracking_lines = first_n_nonempty(reports / "steamtracking-pairing-focus.txt", 8) @@ -64,6 +65,7 @@ def build_draft(run_dir: Path): blocked_count = count_lines(reports / "blocked-visual-assets.tsv") manual_urls = blocked_asset_urls(reports / "blocked-visual-assets.tsv", 10) komodo_blocked = has_nonempty(reports / "komodo-errors.txt") + steamkit_blocked = has_nonempty(reports / "steamkit-pics-errors.txt") steamdb_blocked = has_nonempty(reports / "steamdb-errors.txt") steamvr_blocked = has_nonempty(reports / "steamvr-depots-errors.txt") steamos_blocked = has_nonempty(reports / "steamos-mirror-errors.txt") @@ -104,6 +106,14 @@ def build_draft(run_dir: Path): lines.append(f"- Retrieved visual assets: `{retrieved_count}`") lines.append(f"- Blocked visual assets: `{blocked_count}`") + lines.extend(["", "#### SteamKit / PICS"]) + if steamkit_blocked: + lines.append("- SteamKit/PICS was unavailable or failed in this run. See `steamkit-pics-errors.txt`.") + if steamkit_lines: + lines.extend([f"- `{line}`" for line in steamkit_lines]) + elif not steamkit_blocked: + lines.append("- No SteamKit/PICS key-line report found.") + lines.extend(["", "#### SteamDB"]) if steamdb_blocked: lines.append("- SteamDB had blocked or failed fetches in this run. See `steamdb-errors.txt`.") diff --git a/scripts/run_watch.sh b/scripts/run_watch.sh index 3c53201..d977bee 100755 --- a/scripts/run_watch.sh +++ b/scripts/run_watch.sh @@ -44,6 +44,7 @@ printf '%s\n' "Run dir: $RUN_DIR" printf '%s\n' "Run note: $RUN_NOTE" "$SCRIPT_DIR/check_komodo.sh" "$RUN_DIR" +"$SCRIPT_DIR/check_steamkit_pics.sh" "$RUN_DIR" "$SCRIPT_DIR/check_steamdb.sh" "$RUN_DIR" "$SCRIPT_DIR/check_steamtracking.sh" "$RUN_DIR" "$TRACKING_DIR" "$SCRIPT_DIR/check_steamvr_depots.sh" "$RUN_DIR" diff --git a/scripts/write_run_summary.py b/scripts/write_run_summary.py index 6389a44..82b074e 100755 --- a/scripts/write_run_summary.py +++ b/scripts/write_run_summary.py @@ -119,6 +119,7 @@ def build(run_dir: Path): "## At A Glance", "", f"- Komodo blocked: `{'yes' if count(reports / 'komodo-errors.txt') else 'no'}`", + f"- SteamKit/PICS blocked: `{'yes' if count(reports / 'steamkit-pics-errors.txt') else 'no'}`", f"- SteamDB blocked: `{'yes' if count(reports / 'steamdb-errors.txt') else 'no'}`", f"- SteamVR depot metadata blocked: `{'yes' if count(reports / 'steamvr-depots-errors.txt') else 'no'}`", f"- SteamOS mirror metadata blocked: `{'yes' if count(reports / 'steamos-mirror-errors.txt') else 'no'}`", @@ -136,6 +137,7 @@ def build(run_dir: Path): f"- Retrieved assets: `{reports / 'retrieved-visual-assets.tsv'}`", f"- Blocked assets: `{reports / 'blocked-visual-assets.tsv'}`", f"- Manual retry URLs: `{reports / 'manual-asset-urls.txt'}`", + f"- SteamKit/PICS packages: `{reports / 'steamkit-pics-packages.tsv'}`", f"- Customs shipments: `{reports / 'customs-shipments.md'}`", ] @@ -145,6 +147,7 @@ def build(run_dir: Path): lines.extend(["", "## Notable Lines", ""]) notable = [] + notable.extend([f"- SteamKit/PICS: `{line}`" for line in filtered_first(reports / "steamkit-pics-key-lines.txt", 8)]) notable.extend([f"- SteamDB: `{line}`" for line in filtered_first(reports / "steamdb-key-lines.txt", 5)]) notable.extend([f"- SteamTracking: `{line}`" for line in filtered_first(reports / "steamtracking-pairing-focus.txt", 5)]) notable.extend([f"- SteamVR depots: `{line}`" for line in filtered_first(reports / "steamvr-depots-key-lines.txt", 5)]) diff --git a/tests/test_check_steamkit_pics.py b/tests/test_check_steamkit_pics.py new file mode 100644 index 0000000..e621c63 --- /dev/null +++ b/tests/test_check_steamkit_pics.py @@ -0,0 +1,296 @@ +import os +import stat +import subprocess +import textwrap +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + + +ROOT = Path(__file__).resolve().parents[1] + + +def write_executable(path, content): + path.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +class CheckSteamKitPicsTests(unittest.TestCase): + def test_missing_credentials_writes_nonfatal_unavailable_report(self): + tmp = TemporaryDirectory() + self.addCleanup(tmp.cleanup) + tmp_path = Path(tmp.name) + run_dir = tmp_path / "run" + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + + write_executable( + fake_bin / "dotnet", + """ + #!/bin/sh + echo "dotnet should not be called without credentials" >&2 + exit 9 + """, + ) + + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "STEAMKIT_ENV_FILE": str(tmp_path / "missing-env.sh"), + } + ) + 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) + report = (run_dir / "reports" / "steamkit-pics-packages.tsv").read_text( + encoding="utf-8" + ) + self.assertIn("package\tSteam Frame\t1629484\tmissing_credentials", report) + self.assertIn("app\tSteam Machine\t4165910\tmissing_credentials", report) + + key_lines = (run_dir / "reports" / "steamkit-pics-key-lines.txt").read_text( + encoding="utf-8" + ) + self.assertIn("SteamKit/PICS unavailable", key_lines) + errors = (run_dir / "reports" / "steamkit-pics-errors.txt").read_text( + encoding="utf-8" + ) + self.assertIn("STEAMKIT_USERNAME/STEAMKIT_PASSWORD", errors) + + def test_invokes_dotnet_helper_when_credentials_are_available(self): + tmp = TemporaryDirectory() + self.addCleanup(tmp.cleanup) + tmp_path = Path(tmp.name) + run_dir = tmp_path / "run" + 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_dir="" + report="" + key_lines="" + while [ "$#" -gt 0 ]; do + case "$1" in + --out-dir) + shift + out_dir="$1" + ;; + --report) + shift + report="$1" + ;; + --key-lines) + shift + key_lines="$1" + ;; + esac + shift || true + done + + mkdir -p "$out_dir" "$(dirname "$report")" "$(dirname "$key_lines")" + printf '%s\\n' '{"packages":{"1629484":{"change_number":35500001}}}' > "$out_dir/pics-product-info.json" + { + printf '%s\\n' 'type product id status changenumber related_ids details' + printf '%s\\n' 'package Steam Frame 1629484 available 35500001 4165890 apps=4165890:Steam Frame' + } > "$report" + { + printf '%s\\n' 'SteamKit/PICS package snapshot:' + printf '%s\\n' 'package Steam Frame 1629484 available 35500001 4165890 apps=4165890:Steam Frame' + } > "$key_lines" + """, + ) + + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "STEAMKIT_ENV_FILE": str(tmp_path / "missing-env.sh"), + "STEAMKIT_USERNAME": "watcher", + "STEAMKIT_PASSWORD": "secret", + "FAKE_DOTNET_LOG": str(log_path), + } + ) + + 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("run --project", dotnet_args) + self.assertIn("tools/steamkit-pics", dotnet_args) + + report = (run_dir / "reports" / "steamkit-pics-packages.tsv").read_text( + encoding="utf-8" + ) + self.assertIn("package\tSteam Frame\t1629484\tavailable\t35500001", report) + self.assertTrue( + (run_dir / "api" / "steamkit" / "pics-product-info.json").exists() + ) + + def test_exports_credentials_loaded_from_local_env_file_to_dotnet_helper(self): + tmp = TemporaryDirectory() + self.addCleanup(tmp.cleanup) + tmp_path = Path(tmp.name) + run_dir = tmp_path / "run" + env_file = tmp_path / "steamkit-env.sh" + env_file.write_text( + "STEAMKIT_USERNAME='watcher-from-file'\n" + "STEAMKIT_PASSWORD='secret-from-file'\n", + encoding="utf-8", + ) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + + write_executable( + fake_bin / "dotnet", + """ + #!/bin/sh + if [ "$STEAMKIT_USERNAME" != "watcher-from-file" ]; then + echo "missing username export" >&2 + exit 7 + fi + if [ "$STEAMKIT_PASSWORD" != "secret-from-file" ]; then + echo "missing password export" >&2 + exit 8 + fi + + out_dir="" + report="" + key_lines="" + while [ "$#" -gt 0 ]; do + case "$1" in + --out-dir) + shift + out_dir="$1" + ;; + --report) + shift + report="$1" + ;; + --key-lines) + shift + key_lines="$1" + ;; + esac + shift || true + done + + mkdir -p "$out_dir" "$(dirname "$report")" "$(dirname "$key_lines")" + printf '%s\\n' '{}' > "$out_dir/pics-product-info.json" + printf '%s\\n' 'type product id status changenumber related_ids details' > "$report" + printf '%s\\n' 'SteamKit/PICS package snapshot:' > "$key_lines" + """, + ) + + env = os.environ.copy() + env.update( + { + "PATH": f"{fake_bin}:{env['PATH']}", + "STEAMKIT_ENV_FILE": str(env_file), + } + ) + 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) + errors = (run_dir / "reports" / "steamkit-pics-errors.txt").read_text( + encoding="utf-8" + ) + self.assertEqual("", errors) + + +class SteamKitSummaryTests(unittest.TestCase): + def test_status_draft_and_run_summary_include_steamkit_outputs(self): + tmp = TemporaryDirectory() + self.addCleanup(tmp.cleanup) + run_dir = Path(tmp.name) / "2026-05-11" + reports = run_dir / "reports" + reports.mkdir(parents=True) + (reports / "steamkit-pics-key-lines.txt").write_text( + "SteamKit/PICS package snapshot:\n" + "package\tSteam Frame\t1629484\tavailable\t35500001\t4165890\tapps=4165890:Steam Frame\n", + encoding="utf-8", + ) + (reports / "steamkit-pics-errors.txt").write_text("", encoding="utf-8") + + summary = subprocess.run( + [ + "python3", + str(ROOT / "scripts" / "write_run_summary.py"), + "--run-dir", + str(run_dir), + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + draft = subprocess.run( + [ + "python3", + str(ROOT / "scripts" / "draft_status_update.py"), + "--run-dir", + str(run_dir), + ], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + self.assertEqual("", summary.stderr) + self.assertEqual(0, summary.returncode) + self.assertEqual("", draft.stderr) + self.assertEqual(0, draft.returncode) + + run_summary = (reports / "run-summary.md").read_text(encoding="utf-8") + self.assertIn("- SteamKit/PICS blocked: `no`", run_summary) + self.assertIn("- SteamKit/PICS: `package Steam Frame 1629484", run_summary) + + status_draft = (reports / "status-draft.md").read_text(encoding="utf-8") + self.assertIn("#### SteamKit / PICS", status_draft) + self.assertIn("- `package\tSteam Frame\t1629484", status_draft) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/steamkit-pics/Program.cs b/tools/steamkit-pics/Program.cs new file mode 100644 index 0000000..b8df14d --- /dev/null +++ b/tools/steamkit-pics/Program.cs @@ -0,0 +1,381 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using SteamKit2; + +var options = Args.Parse(args); +Directory.CreateDirectory(options.OutDir); +Directory.CreateDirectory(Path.GetDirectoryName(options.ReportPath) ?? "."); +Directory.CreateDirectory(Path.GetDirectoryName(options.KeyLinesPath) ?? "."); + +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 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, +}; + +await File.WriteAllTextAsync( + Path.Combine(options.OutDir, "pics-product-info.json"), + JsonSerializer.Serialize(snapshot, jsonOptions) + Environment.NewLine, + cts.Token +); + +var rows = ReportRows.Build(snapshot); +await File.WriteAllLinesAsync(options.ReportPath, rows.Select(row => string.Join('\t', row.Select(CleanTsv))), cts.Token); + +var keyLines = new List { "SteamKit/PICS package snapshot:" }; +keyLines.AddRange(rows.Take(40).Select(row => string.Join('\t', row.Select(CleanTsv)))); +await File.WriteAllLinesAsync(options.KeyLinesPath, keyLines, cts.Token); + +static string? Env(string name) => Environment.GetEnvironmentVariable(name); + +static string CleanTsv(string? value) => (value ?? "").Replace('\t', ' ').Replace('\n', ' ').Trim(); + +sealed record Args(string OutDir, string ReportPath, string KeyLinesPath) +{ + public static Args Parse(string[] args) + { + var values = new Dictionary(StringComparer.Ordinal); + for (var index = 0; index < args.Length; index++) + { + if (!args[index].StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + if (index + 1 >= args.Length) + { + throw new ArgumentException($"Missing value for {args[index]}."); + } + + values[args[index]] = args[++index]; + } + + return new Args( + Require(values, "--out-dir"), + Require(values, "--report"), + Require(values, "--key-lines") + ); + } + + static string Require(Dictionary values, string key) + => values.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value) + ? value + : throw new ArgumentException($"{key} is required."); +} + +static class Targets +{ + public static readonly Dictionary Apps = new() + { + [4165870] = "Steam Controller", + [4165890] = "Steam Frame", + [4165910] = "Steam Machine", + }; + + public static readonly Dictionary Packages = new() + { + [1558609] = "Steam Controller", + [1629446] = "Steam Machine", + [1629447] = "Steam Machine", + [1629458] = "Steam Machine", + [1629460] = "Steam Machine", + [1629484] = "Steam Frame", + [1629486] = "Steam Frame", + }; +} + +static class PicsClient +{ + public static async Task FetchAsync( + string username, + string? password, + string? accessToken, + string? authCode, + string? twoFactorCode, + CancellationToken cancellationToken) + { + var steamClient = new SteamClient(); + var manager = new CallbackManager(steamClient); + var steamUser = steamClient.GetHandler() ?? throw new InvalidOperationException("SteamUser handler unavailable."); + var steamApps = steamClient.GetHandler() ?? throw new InvalidOperationException("SteamApps handler unavailable."); + var login = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + manager.Subscribe(_ => + { + steamUser.LogOn(new SteamUser.LogOnDetails + { + Username = username, + Password = password, + AccessToken = accessToken, + AuthCode = authCode, + TwoFactorCode = twoFactorCode, + ShouldRememberPassword = false, + }); + }); + + manager.Subscribe(callback => + { + if (callback.Result == EResult.OK) + { + login.TrySetResult(); + return; + } + + var extra = callback.ExtendedResult == EResult.Invalid + ? "" + : $" extended={callback.ExtendedResult}"; + login.TrySetException(new InvalidOperationException( + $"Steam logon failed: {callback.Result}{extra}. If Steam Guard is required, set STEAMKIT_AUTH_CODE or STEAMKIT_TWO_FACTOR_CODE." + )); + }); + + manager.Subscribe(callback => + { + if (!callback.UserInitiated) + { + disconnected.TrySetResult("Steam disconnected before the PICS request completed."); + } + }); + + steamClient.Connect(); + await PumpUntilAsync(manager, login.Task, disconnected.Task, cancellationToken); + + var aggregate = new ProductAggregate(); + var productInfo = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var appRequests = Targets.Apps.Keys.Select(id => new SteamApps.PICSRequest(id, 0)).ToList(); + var packageRequests = Targets.Packages.Keys.Select(id => new SteamApps.PICSRequest(id, 0)).ToList(); + var jobId = steamApps.PICSGetProductInfo(appRequests, packageRequests, metaDataOnly: false); + + using var subscription = manager.Subscribe(jobId, callback => + { + aggregate.Add(callback); + if (!callback.ResponsePending) + { + productInfo.TrySetResult(); + } + }); + + await PumpUntilAsync(manager, productInfo.Task, disconnected.Task, cancellationToken); + + steamUser.LogOff(); + steamClient.Disconnect(); + + return aggregate.ToSnapshot(); + } + + static async Task PumpUntilAsync( + CallbackManager manager, + Task primary, + Task disconnected, + CancellationToken cancellationToken) + { + while (!primary.IsCompleted) + { + if (disconnected.IsCompleted) + { + throw new InvalidOperationException(await disconnected); + } + + await manager.RunWaitCallbackAsync(cancellationToken); + } + + await primary; + } +} + +sealed class ProductAggregate +{ + readonly Dictionary apps = new(); + readonly Dictionary packages = new(); + readonly HashSet unknownApps = new(); + readonly HashSet unknownPackages = new(); + + public void Add(SteamApps.PICSProductInfoCallback callback) + { + foreach (var pair in callback.Apps) + { + apps[pair.Key] = pair.Value; + } + + foreach (var pair in callback.Packages) + { + packages[pair.Key] = pair.Value; + } + + foreach (var app in callback.UnknownApps) + { + unknownApps.Add(app); + } + + foreach (var package in callback.UnknownPackages) + { + unknownPackages.Add(package); + } + } + + public PicsSnapshot ToSnapshot() + { + return new PicsSnapshot( + DateTimeOffset.UtcNow, + apps.ToDictionary(pair => pair.Key.ToString(), pair => ProductInfo.From(pair.Value)), + packages.ToDictionary(pair => pair.Key.ToString(), pair => ProductInfo.From(pair.Value)), + unknownApps.Order().ToArray(), + unknownPackages.Order().ToArray() + ); + } +} + +sealed record PicsSnapshot( + DateTimeOffset FetchedAtUtc, + Dictionary Apps, + Dictionary Packages, + uint[] UnknownApps, + uint[] UnknownPackages +); + +sealed record ProductInfo( + uint Id, + uint ChangeNumber, + bool MissingToken, + bool OnlyPublic, + bool UseHttp, + string? HttpUri, + string? ShaHash, + KeyValueNode? KeyValues) +{ + public static ProductInfo From(SteamApps.PICSProductInfoCallback.PICSProductInfo info) + => new( + info.ID, + info.ChangeNumber, + info.MissingToken, + info.OnlyPublic, + info.UseHttp, + info.HttpUri?.ToString(), + info.SHAHash is null ? null : Convert.ToHexString(info.SHAHash), + info.KeyValues is null ? null : KeyValueNode.From(info.KeyValues) + ); +} + +sealed record KeyValueNode(string Name, string? Value, List Children) +{ + public static KeyValueNode From(KeyValue keyValue) + => new( + keyValue.Name ?? "", + keyValue.Value, + keyValue.Children.Select(From).ToList() + ); + + public KeyValueNode? Child(string name) + => Children.FirstOrDefault(child => string.Equals(child.Name, name, StringComparison.OrdinalIgnoreCase)); + + public string? Path(params string[] names) + { + var current = this; + foreach (var name in names) + { + current = current.Child(name); + if (current is null) + { + return null; + } + } + + return current.Value; + } +} + +static class ReportRows +{ + public static List Build(PicsSnapshot snapshot) + { + var rows = new List + { + new[] { "type", "product", "id", "status", "changenumber", "related_ids", "details" }, + }; + + foreach (var (id, product) in Targets.Apps.OrderBy(pair => pair.Key)) + { + var key = id.ToString(); + rows.Add(snapshot.Apps.TryGetValue(key, out var info) + ? AppRow(product, info) + : MissingRow("app", product, id, snapshot.UnknownApps.Contains(id) ? "unknown" : "missing")); + } + + foreach (var (id, product) in Targets.Packages.OrderBy(pair => pair.Key)) + { + var key = id.ToString(); + rows.Add(snapshot.Packages.TryGetValue(key, out var info) + ? PackageRow(product, info) + : MissingRow("package", product, id, snapshot.UnknownPackages.Contains(id) ? "unknown" : "missing")); + } + + return rows; + } + + static string[] AppRow(string product, ProductInfo info) + { + var name = info.KeyValues?.Path("common", "name") ?? ""; + var depotIds = info.KeyValues?.Child("depots")?.Children + .Where(child => uint.TryParse(child.Name, out _)) + .Select(child => child.Name) + .Order() + .ToArray() ?? Array.Empty(); + var branchCount = info.KeyValues?.Child("depots")?.Child("branches")?.Children.Count ?? 0; + var status = info.MissingToken ? "missing_token" : "available"; + var details = $"name={name}; depots={depotIds.Length}; branches={branchCount}; only_public={info.OnlyPublic}"; + + return new[] + { + "app", + product, + info.Id.ToString(), + status, + info.ChangeNumber.ToString(), + string.Join(",", depotIds), + details, + }; + } + + static string[] PackageRow(string product, ProductInfo info) + { + var appIds = info.KeyValues?.Child("appids")?.Children + .Select(child => child.Name) + .Where(name => uint.TryParse(name, out _)) + .Order() + .ToArray() ?? Array.Empty(); + var status = info.MissingToken ? "missing_token" : "available"; + var details = $"apps={string.Join(",", appIds)}; only_public={info.OnlyPublic}"; + + return new[] + { + "package", + product, + info.Id.ToString(), + status, + info.ChangeNumber.ToString(), + string.Join(",", appIds), + details, + }; + } + + static string[] MissingRow(string type, string product, uint id, string status) + => new[] { type, product, id.ToString(), status, "", "", "PICS product info was not returned" }; +} diff --git a/tools/steamkit-pics/SteamHardwarePics.csproj b/tools/steamkit-pics/SteamHardwarePics.csproj new file mode 100644 index 0000000..fcc921b --- /dev/null +++ b/tools/steamkit-pics/SteamHardwarePics.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + + + + + +