mirror of
https://github.com/seanbetts/steam-hardware-watch.git
synced 2026-10-06 03:00:18 +02:00
Add SteamKit PICS watcher
This commit is contained in:
1 parent
d930c296b6
commit
9a66ff4f48
12 files changed
+880
-2
No files matched your search
@@ -3,6 +3,8 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
# Local agent/browser state
|
# Local agent/browser state
|
||||||
|
|||||||
@@ -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:
|
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
|
- check `SteamVR` depot metadata and the public SteamOS package mirror
|
||||||
- save raw artifacts for each run
|
- save raw artifacts for each run
|
||||||
- compare against the previous run
|
- compare against the previous run
|
||||||
@@ -65,6 +65,7 @@ Required:
|
|||||||
|
|
||||||
Recommended:
|
Recommended:
|
||||||
|
|
||||||
|
- `.NET SDK 10` for the SteamKit/PICS helper
|
||||||
- `playwright-cli`
|
- `playwright-cli`
|
||||||
- Google Chrome or Chromium available to Playwright
|
- Google Chrome or Chromium available to Playwright
|
||||||
- macOS `open` command with Google Chrome installed for the Komodo background bootstrap helper
|
- 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.
|
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
|
## Output
|
||||||
|
|
||||||
Each run writes:
|
Each run writes:
|
||||||
@@ -132,6 +178,7 @@ Each run writes:
|
|||||||
- `retrieved-visual-assets.tsv`
|
- `retrieved-visual-assets.tsv`
|
||||||
- `blocked-visual-assets.tsv`
|
- `blocked-visual-assets.tsv`
|
||||||
- `manual-asset-urls.txt`
|
- `manual-asset-urls.txt`
|
||||||
|
- `steamkit-pics-packages.tsv`
|
||||||
- `steamvr-depots-key-lines.txt`
|
- `steamvr-depots-key-lines.txt`
|
||||||
- `steamos-mirror-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
|
## Current Known Limits
|
||||||
|
|
||||||
- `Komodo` can block both `curl` and fresh browser automation.
|
- `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.
|
- `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.
|
- `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.
|
- The SteamOS mirror is public, but package names are not proof of product launch state without corroborating evidence.
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Use the source helpers when available:
|
|||||||
|
|
||||||
- [scripts/run_watch.sh](scripts/run_watch.sh)
|
- [scripts/run_watch.sh](scripts/run_watch.sh)
|
||||||
- [scripts/check_komodo.sh](scripts/check_komodo.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_steamdb.sh](scripts/check_steamdb.sh)
|
||||||
- [scripts/check_steamtracking.sh](scripts/check_steamtracking.sh)
|
- [scripts/check_steamtracking.sh](scripts/check_steamtracking.sh)
|
||||||
- [scripts/check_steamvr_depots.sh](scripts/check_steamvr_depots.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 narrow SteamKit2 helper when available. It should:
|
||||||
|
|
||||||
- use a separate low-risk Steam account, not the user's main account
|
- 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
|
- poll only watched Valve hardware app and package IDs
|
||||||
- save raw app/package product-info snapshots and changenumbers
|
- 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
|
- compare snapshots against the previous run
|
||||||
- avoid protected depot downloads and aggressive polling
|
- 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:
|
It will:
|
||||||
|
|
||||||
1. initialize the run folder
|
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
|
3. auto-detect the previous run folder when possible
|
||||||
4. write a comparison report into the current run folder
|
4. write a comparison report into the current run folder
|
||||||
5. save discovered visual assets into the current run folder
|
5. save discovered visual assets into the current run folder
|
||||||
|
|||||||
@@ -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
|
- depot and branch metadata exposed in app product info, when present
|
||||||
- raw JSON or normalized structured output suitable for diffing against the previous run
|
- 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:
|
Operational constraints:
|
||||||
|
|
||||||
- use a separate Steam account, not the user's main account
|
- use a separate Steam account, not the user's main account
|
||||||
|
|||||||
Executable
+114
@@ -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"
|
||||||
@@ -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 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 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_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, "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, "SteamVR depots", "steamvr-depots-key-lines.txt"))
|
||||||
deltas.extend(compare_text_lines(prev_dir, curr_dir, "SteamOS mirror", "steamos-mirror-key-lines.txt"))
|
deltas.extend(compare_text_lines(prev_dir, curr_dir, "SteamOS mirror", "steamos-mirror-key-lines.txt"))
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ def build_draft(run_dir: Path):
|
|||||||
compare_lines = [line for line in read_lines(compare_file) if line.startswith("- ")][:12]
|
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)
|
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)
|
steamdb_lines = first_n_nonempty(reports / "steamdb-key-lines.txt", 8)
|
||||||
valve_lines = first_n_nonempty(reports / "valve-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)
|
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")
|
blocked_count = count_lines(reports / "blocked-visual-assets.tsv")
|
||||||
manual_urls = blocked_asset_urls(reports / "blocked-visual-assets.tsv", 10)
|
manual_urls = blocked_asset_urls(reports / "blocked-visual-assets.tsv", 10)
|
||||||
komodo_blocked = has_nonempty(reports / "komodo-errors.txt")
|
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")
|
steamdb_blocked = has_nonempty(reports / "steamdb-errors.txt")
|
||||||
steamvr_blocked = has_nonempty(reports / "steamvr-depots-errors.txt")
|
steamvr_blocked = has_nonempty(reports / "steamvr-depots-errors.txt")
|
||||||
steamos_blocked = has_nonempty(reports / "steamos-mirror-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"- Retrieved visual assets: `{retrieved_count}`")
|
||||||
lines.append(f"- Blocked visual assets: `{blocked_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"])
|
lines.extend(["", "#### SteamDB"])
|
||||||
if steamdb_blocked:
|
if steamdb_blocked:
|
||||||
lines.append("- SteamDB had blocked or failed fetches in this run. See `steamdb-errors.txt`.")
|
lines.append("- SteamDB had blocked or failed fetches in this run. See `steamdb-errors.txt`.")
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ printf '%s\n' "Run dir: $RUN_DIR"
|
|||||||
printf '%s\n' "Run note: $RUN_NOTE"
|
printf '%s\n' "Run note: $RUN_NOTE"
|
||||||
|
|
||||||
"$SCRIPT_DIR/check_komodo.sh" "$RUN_DIR"
|
"$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_steamdb.sh" "$RUN_DIR"
|
||||||
"$SCRIPT_DIR/check_steamtracking.sh" "$RUN_DIR" "$TRACKING_DIR"
|
"$SCRIPT_DIR/check_steamtracking.sh" "$RUN_DIR" "$TRACKING_DIR"
|
||||||
"$SCRIPT_DIR/check_steamvr_depots.sh" "$RUN_DIR"
|
"$SCRIPT_DIR/check_steamvr_depots.sh" "$RUN_DIR"
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ def build(run_dir: Path):
|
|||||||
"## At A Glance",
|
"## At A Glance",
|
||||||
"",
|
"",
|
||||||
f"- Komodo blocked: `{'yes' if count(reports / 'komodo-errors.txt') else 'no'}`",
|
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"- 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"- 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'}`",
|
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"- Retrieved assets: `{reports / 'retrieved-visual-assets.tsv'}`",
|
||||||
f"- Blocked assets: `{reports / 'blocked-visual-assets.tsv'}`",
|
f"- Blocked assets: `{reports / 'blocked-visual-assets.tsv'}`",
|
||||||
f"- Manual retry URLs: `{reports / 'manual-asset-urls.txt'}`",
|
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'}`",
|
f"- Customs shipments: `{reports / 'customs-shipments.md'}`",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ def build(run_dir: Path):
|
|||||||
lines.extend(["", "## Notable Lines", ""])
|
lines.extend(["", "## Notable Lines", ""])
|
||||||
|
|
||||||
notable = []
|
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"- 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"- 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)])
|
notable.extend([f"- SteamVR depots: `{line}`" for line in filtered_first(reports / "steamvr-depots-key-lines.txt", 5)])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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<string> { "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<string, string>(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<string, string> 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<uint, string> Apps = new()
|
||||||
|
{
|
||||||
|
[4165870] = "Steam Controller",
|
||||||
|
[4165890] = "Steam Frame",
|
||||||
|
[4165910] = "Steam Machine",
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly Dictionary<uint, string> 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<PicsSnapshot> 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<SteamUser>() ?? throw new InvalidOperationException("SteamUser handler unavailable.");
|
||||||
|
var steamApps = steamClient.GetHandler<SteamApps>() ?? throw new InvalidOperationException("SteamApps handler unavailable.");
|
||||||
|
var login = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var disconnected = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
manager.Subscribe<SteamClient.ConnectedCallback>(_ =>
|
||||||
|
{
|
||||||
|
steamUser.LogOn(new SteamUser.LogOnDetails
|
||||||
|
{
|
||||||
|
Username = username,
|
||||||
|
Password = password,
|
||||||
|
AccessToken = accessToken,
|
||||||
|
AuthCode = authCode,
|
||||||
|
TwoFactorCode = twoFactorCode,
|
||||||
|
ShouldRememberPassword = false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.Subscribe<SteamUser.LoggedOnCallback>(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<SteamClient.DisconnectedCallback>(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<SteamApps.PICSProductInfoCallback>(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<string> 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<uint, SteamApps.PICSProductInfoCallback.PICSProductInfo> apps = new();
|
||||||
|
readonly Dictionary<uint, SteamApps.PICSProductInfoCallback.PICSProductInfo> packages = new();
|
||||||
|
readonly HashSet<uint> unknownApps = new();
|
||||||
|
readonly HashSet<uint> 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<string, ProductInfo> Apps,
|
||||||
|
Dictionary<string, ProductInfo> 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<KeyValueNode> 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<string[]> Build(PicsSnapshot snapshot)
|
||||||
|
{
|
||||||
|
var rows = new List<string[]>
|
||||||
|
{
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="SteamKit2" Version="3.4.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
Reference in new issue
Block a user