Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changelog/3518.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added
- **GameHacking.org Cheat Proxy** — Cloudflare Workers middleware (`Scripts/cheat-proxy/`) that scrapes GameHacking.org and returns normalised JSON cheat entries with 24h server-side KV caching; reduces scraper fragility and load on the source site
- **Cheat proxy settings** — `useCheatProxy` (default `true`) and `cheatProxyURL` settings allow the proxy endpoint to be enabled/disabled and configured without an app update
- **Automatic proxy fallback** — When the proxy is unreachable or returns no results, `GameHackingOrgLookup` transparently falls back to the existing direct HTML scraper
1 change: 1 addition & 0 deletions PVLibrary/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
.library(
name: "PVMediaCache",
targets: ["PVMediaCache"]
),

Check warning on line 57 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
],
dependencies:
["Support", "Logging", "Hashing",
Expand Down Expand Up @@ -85,7 +85,7 @@
.upToNextMajor(from: "0.3.16")),
.package(name: "LzhArchive", path: "../Dependencies/LzhArchive"),
.package(url: "https://github.com/realm/realm-swift.git",
from: "20.0.0"),

Check warning on line 88 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
],

targets: [
Expand Down Expand Up @@ -125,7 +125,7 @@
.product(name: "RxCocoa", package: "RxSwift"),
.product(name: "RxSwift", package: "RxSwift"),
.product(name: "RxRealm", package: "RxRealm"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),

Check warning on line 128 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
],
resources: [
// TODO: Move Cheats to PVLookup
Expand Down Expand Up @@ -158,7 +158,7 @@
.product(name: "RxRealm", package: "RxRealm"),
.product(name: "RealmSwift", package: "realm-swift"),
.product(name: "ZipArchive", package: "ZipArchive"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),

Check warning on line 161 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
]),
// MARK: ------------ PVMediaCache ------------
.target(
Expand All @@ -174,7 +174,7 @@
.product(name: "RxCocoa", package: "RxSwift"),
.product(name: "RxSwift", package: "RxSwift"),
.product(name: "RxRealm", package: "RxRealm"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),

Check warning on line 177 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
]),
// MARK: ------------ PVFileSystem ------------
.target(
Expand All @@ -192,7 +192,7 @@
.product(name: "RxCocoa", package: "RxSwift"),
.product(name: "RxSwift", package: "RxSwift"),
.product(name: "RxRealm", package: "RxRealm"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),

Check warning on line 195 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
]),
// // MARK: ------------ DirectoryWatcher ------------
// .target(
Expand Down Expand Up @@ -231,7 +231,7 @@
.product(name: "RxSwift", package: "RxSwift"),
.product(name: "RxRealm", package: "RxRealm"),
.product(name: "ZipArchive", package: "ZipArchive"),
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),

Check warning on line 234 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
]),
// MARK: ------------ Tests ------------
.testTarget(
Expand All @@ -243,10 +243,11 @@
"PVLookup",
"PVPrimitives",
"PVFileSystem",
"PVSettings",
.product(name: "RealmSwift", package: "realm-swift"),
.product(name: "ZipArchive", package: "ZipArchive"),

Check warning on line 248 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
]
),

Check warning on line 250 in PVLibrary/Package.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Collection literals should not have trailing commas (trailing_comma)
],
swiftLanguageModes: [.v5],
cLanguageStandard: .gnu18,
Expand Down
111 changes: 109 additions & 2 deletions PVLibrary/Sources/PVLibrary/Cheat/GameHackingOrgLookup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import Foundation
import PVLogging
import PVSettings

// MARK: - GameHackingOrgLookup

Expand Down Expand Up @@ -49,6 +50,10 @@
private static let maxMemoryCacheEntries = 50
/// Minimum seconds between requests to be polite to the server.
private static let minRequestInterval: TimeInterval = 1.0
/// Compile-time default proxy URL. Can be overridden via PVSettings `cheatProxyURL`.
/// Set to a non-empty string after deploying the Cloudflare Worker
/// (see `Scripts/cheat-proxy/README.md`).
static let defaultProxyURL: String = ""

// MARK: - State

Expand Down Expand Up @@ -83,9 +88,26 @@
return diskHit.entries
}

// 3. Network fetch — never throws outward; always returns empty on failure.
// 3. Network fetch — try proxy first (if enabled), then fall back to direct scraping.
DLOG("GameHackingOrgLookup: fetching online for title='\(title)' slug=\(systemSlug ?? "nil")")
let results = await fetchWithFallback(title: title, systemSlug: systemSlug)
let results: [CheatDatabaseEntry]

let proxyURL = resolvedProxyURL()
if !proxyURL.isEmpty, Defaults[.useCheatProxy] {
// fetchFromProxy returns nil when the proxy is unreachable or fails (caller should
// fallback to direct scraping), or a non-nil array when the proxy successfully
// responded — including an empty array meaning "no cheats found" (no fallback needed).
let proxyResults = await fetchFromProxy(title: title, systemSlug: systemSlug, proxyBaseURL: proxyURL)
if let proxyResults {
DLOG("GameHackingOrgLookup: proxy returned \(proxyResults.count) codes for '\(title)'")
Comment on lines +95 to +102
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New proxy-first behavior (URL construction + JSON decoding + fallback when proxy is unreachable/empty) isn’t covered by unit tests. Since PVLibrary already has GameHackingOrgLookupTests, consider adding tests that stub URLSession (via URLProtocol) to validate these proxy scenarios without network access.

Copilot uses AI. Check for mistakes.
results = proxyResults
} else {
DLOG("GameHackingOrgLookup: proxy failed/unreachable, falling back to direct scrape for '\(title)'")
results = await fetchWithFallback(title: title, systemSlug: systemSlug)
}
} else {
results = await fetchWithFallback(title: title, systemSlug: systemSlug)
}

evictMemoryCacheIfNeeded()
memoryCache[key] = (Date(), results)
Expand All @@ -95,8 +117,93 @@
return results
}

// MARK: - Proxy URL Resolution

/// Returns the effective proxy base URL, preferring the user-configured value over the compile-time default.
private func resolvedProxyURL() -> String {
let stored = Defaults[.cheatProxyURL].trimmingCharacters(in: .whitespacesAndNewlines)
if !stored.isEmpty { return stored }
return Self.defaultProxyURL
}

// MARK: - Fetch Logic

/// Fetch cheat entries from the Provenance cheat proxy worker.
///
/// The proxy endpoint is `GET <proxyBaseURL>/cheats?title=<title>&system=<slug>`.
///
/// Returns:
/// - `nil` when the proxy is unreachable, returns a non-2xx status, or a network/decode
/// error occurs — the caller should fall back to direct scraping.
/// - `[]` when the proxy successfully contacted upstream but found no cheats
/// (signalled by `X-Proxy-Status: ok` in the response) — no fallback needed.
/// - `[entries]` when the proxy found results.
private func fetchFromProxy(title: String, systemSlug: String?, proxyBaseURL: String) async -> [CheatDatabaseEntry]? {
var components = URLComponents(string: proxyBaseURL.hasSuffix("/")
? proxyBaseURL + "cheats"
: proxyBaseURL + "/cheats")
var queryItems = [URLQueryItem(name: "title", value: title)]
if let slug = systemSlug {
queryItems.append(URLQueryItem(name: "system", value: slug))
}
components?.queryItems = queryItems

guard let url = components?.url else {
WLOG("GameHackingOrgLookup: invalid proxy URL '\(proxyBaseURL)'")
return nil
}

do {
var request = URLRequest(url: url)
request.setValue("Provenance-Emu/1.0", forHTTPHeaderField: "User-Agent")
request.timeoutInterval = 10
let (data, response) = try await URLSession.shared.data(for: request)
Comment on lines +157 to +160
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchFromProxy uses URLSession.shared with the default cache policy; combined with the worker’s long Cache-Control TTL, this can cause URLSession/URLCache to reuse a cached empty/error proxy response and repeatedly force fallback without recontacting the proxy. Consider setting the request’s cachePolicy (e.g. ignoring local cache) for proxy calls, since the proxy already does its own server-side caching via KV.

Copilot uses AI. Check for mistakes.
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
WLOG("GameHackingOrgLookup: proxy non-200 for '\(title)'")
return nil
}

let raw = try JSONDecoder().decode([ProxyCheatEntry].self, from: data)
if raw.isEmpty {
// Only trust an empty result as "no cheats found" when the proxy confirms it
// successfully contacted upstream via X-Proxy-Status: ok. Without this header
// the empty array may be a transient error — fall back to direct scraping.
let proxyStatus = http.value(forHTTPHeaderField: "X-Proxy-Status")
guard proxyStatus == "ok" else {
DLOG("GameHackingOrgLookup: proxy returned empty without ok status for '\(title)' — falling back")
return nil
}
return []
}

return raw.enumerated().map { index, entry in
CheatDatabaseEntry(
id: Self.idOffset + index,
cheatName: entry.name,
cheatCode: entry.code,
cheatDescription: nil,
deviceName: "GameHacking.org",
deviceFormat: nil,
category: entry.category ?? "General",
romTitle: title,
systemName: systemSlug,
isOnlineResult: true
)
}
} catch {
WLOG("GameHackingOrgLookup: proxy fetch error for '\(title)': \(error)")
return nil
}
}

/// JSON model returned by the cheat proxy worker.
private struct ProxyCheatEntry: Decodable {
let name: String
let code: String
let category: String?
}

/// Try fetching with system filter first; fall back to no-system search on failure.
private func fetchWithFallback(title: String, systemSlug: String?) async -> [CheatDatabaseEntry] {
// Strategy 1: search with system filter (if we have a slug)
Expand Down Expand Up @@ -582,7 +689,7 @@
/// Decode common HTML entities.
var htmlDecoded: String {
self
.replacingOccurrences(of: "&amp;", with: "&")

Check warning on line 692 in PVLibrary/Sources/PVLibrary/Cheat/GameHackingOrgLookup.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

There should be no space before and one after any comma (comma)
.replacingOccurrences(of: "&lt;", with: "<")
.replacingOccurrences(of: "&gt;", with: ">")
.replacingOccurrences(of: "&quot;", with: "\"")
Expand Down
170 changes: 169 additions & 1 deletion PVLibrary/Tests/PVLibraryTests/GameHackingOrgLookupTests.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// GameHackingOrgLookupTests.swift
// PVLibraryTests
//
// Unit tests for GameHackingOrgLookup's HTML parsing strategies.
// Unit tests for GameHackingOrgLookup's HTML parsing strategies and proxy path.
// Tests run against static HTML fixtures — no network required.

@testable import PVLibrary
import Defaults
import PVSettings
import XCTest

final class GameHackingOrgLookupTests: XCTestCase {
Expand Down Expand Up @@ -114,4 +116,170 @@ final class GameHackingOrgLookupTests: XCTestCase {
let result = await lookup.looksLikeCode("Infinite Lives")
XCTAssertFalse(result)
}

// MARK: - Proxy Path (URLProtocol stubs)

func testSearchCheats_proxyReturnsResults() async {
XCTAssertTrue(URLProtocol.registerClass(ProxyCannedProtocol.self), "URLProtocol registration failed — test may hit real network")
defer { URLProtocol.unregisterClass(ProxyCannedProtocol.self) }

let json = #"[{"name":"Infinite Lives","code":"DEADBEEF00000001","category":"General"}]"#
ProxyCannedProtocol.cannedJSON = Data(json.utf8)
ProxyCannedProtocol.statusCode = 200
ProxyCannedProtocol.cannedHeaders = ["X-Proxy-Status": "ok"]
ProxyCannedProtocol.lastRequest = nil
defer { ProxyCannedProtocol.cannedHeaders = [:] }

Defaults[.useCheatProxy] = true
Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid"
defer {
Defaults.reset(.useCheatProxy)
Defaults.reset(.cheatProxyURL)
}

let title = "ProxyHappyPath_\(UUID().uuidString)"
let entries = await GameHackingOrgLookup.shared.searchCheats(title: title, systemSlug: "n64")

// The proxy URL should have been contacted with the correct path/query
let intercepted = ProxyCannedProtocol.lastRequest?.url?.absoluteString ?? ""
XCTAssertTrue(intercepted.contains("/cheats"), "Expected /cheats in proxy request URL, got: \(intercepted)")
XCTAssertTrue(intercepted.contains("title="), "Expected title= query param in proxy request URL")

// Results should be decoded from the proxy JSON
XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries.first?.cheatName, "Infinite Lives")
XCTAssertEqual(entries.first?.cheatCode, "DEADBEEF00000001")
XCTAssertEqual(entries.first?.deviceName, "GameHacking.org")
XCTAssertTrue(entries.first?.isOnlineResult ?? false)
}

func testSearchCheats_proxyReturnsEmpty_noFallback() async {
// Proxy returns [] with X-Proxy-Status: ok — meaning "upstream confirmed no cheats".
// searchCheats should trust this and NOT fall back to direct scraping.
// DirectScrapeBlockerProtocol is registered to ensure no gamehacking.org request is made.
XCTAssertTrue(URLProtocol.registerClass(ProxyCannedProtocol.self), "URLProtocol registration failed — test may hit real network")
XCTAssertTrue(URLProtocol.registerClass(DirectScrapeBlockerProtocol.self), "URLProtocol registration failed — test may hit real network")
defer {
URLProtocol.unregisterClass(ProxyCannedProtocol.self)
URLProtocol.unregisterClass(DirectScrapeBlockerProtocol.self)
}
Comment on lines +122 to +165
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These proxy tests rely on URLProtocol.registerClass, which mutates global process-wide state and uses shared static variables (cannedJSON, lastRequest, requestCount). If PVLibrary tests are ever run in parallel (or other tests also use URLProtocol), this can become flaky due to cross-test interference. A more isolated approach is to use a dedicated URLSession with an ephemeral URLSessionConfiguration that sets protocolClasses, and inject that session into GameHackingOrgLookup for testing.

Copilot uses AI. Check for mistakes.

ProxyCannedProtocol.cannedJSON = Data("[]".utf8)
ProxyCannedProtocol.statusCode = 200
// X-Proxy-Status: ok tells the client the proxy successfully ran and found nothing
ProxyCannedProtocol.cannedHeaders = ["X-Proxy-Status": "ok"]
ProxyCannedProtocol.lastRequest = nil
DirectScrapeBlockerProtocol.requestCount = 0
defer {
ProxyCannedProtocol.cannedHeaders = [:]
DirectScrapeBlockerProtocol.requestCount = 0
}

Defaults[.useCheatProxy] = true
Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid"
defer {
Defaults.reset(.useCheatProxy)
Defaults.reset(.cheatProxyURL)
}

let title = "ProxyEmptyNoFallback_\(UUID().uuidString)"
let entries = await GameHackingOrgLookup.shared.searchCheats(title: title, systemSlug: nil)

// Proxy was contacted and returned empty with ok status — no direct scrape should happen
XCTAssertTrue(entries.isEmpty)
let intercepted = ProxyCannedProtocol.lastRequest?.url?.absoluteString ?? ""
XCTAssertTrue(intercepted.contains("/cheats"), "Proxy should have been contacted")
XCTAssertEqual(DirectScrapeBlockerProtocol.requestCount, 0, "Direct scrape should NOT occur when proxy confirms no cheats")
}

func testSearchCheats_proxyDisabled_doesNotContactProxy() async {
// Proxy is disabled — only the direct scrape path runs.
// DirectScrapeBlockerProtocol intercepts gamehacking.org requests so no real
// network call is made; it returns empty HTML so the scrape yields no results.
XCTAssertTrue(URLProtocol.registerClass(ProxyCannedProtocol.self), "URLProtocol registration failed — test may hit real network")
XCTAssertTrue(URLProtocol.registerClass(DirectScrapeBlockerProtocol.self), "URLProtocol registration failed — test may hit real network")
defer {
URLProtocol.unregisterClass(ProxyCannedProtocol.self)
URLProtocol.unregisterClass(DirectScrapeBlockerProtocol.self)
}

ProxyCannedProtocol.cannedJSON = Data()
ProxyCannedProtocol.lastRequest = nil
DirectScrapeBlockerProtocol.requestCount = 0
defer { DirectScrapeBlockerProtocol.requestCount = 0 }

Defaults[.useCheatProxy] = false
Defaults[.cheatProxyURL] = "https://test.proxy.pvemu.invalid"
defer {
Defaults.reset(.useCheatProxy)
Defaults.reset(.cheatProxyURL)
}

let title = "ProxyDisabled_\(UUID().uuidString)"
_ = await GameHackingOrgLookup.shared.searchCheats(title: title, systemSlug: nil)
// The proxy should not have been contacted when useCheatProxy is false
XCTAssertNil(ProxyCannedProtocol.lastRequest, "Proxy should not be contacted when useCheatProxy is false")
}
Comment on lines +195 to +222
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

testSearchCheats_proxyDisabled_doesNotContactProxy calls searchCheats with the proxy disabled, which forces the production HTML scraping path and will hit GameHacking.org over the network (ProxyCannedProtocol does not intercept it). This breaks the file’s stated goal of “no network required” and can make the suite flaky/slow. If the intent is only to assert the proxy isn’t used, consider stubbing the scrape URLs as well or adding an injected session so the scrape path can be short-circuited in tests.

Copilot uses AI. Check for mistakes.
}

// MARK: - URLProtocol stub for proxy tests

/// Intercepts requests to the test proxy host and returns canned JSON.
private final class ProxyCannedProtocol: URLProtocol {
static var cannedJSON: Data = Data()
static var statusCode: Int = 200
static var cannedHeaders: [String: String] = [:]
static var lastRequest: URLRequest?

override class func canInit(with request: URLRequest) -> Bool {
request.url?.host?.contains("test.proxy.pvemu.invalid") ?? false
}

override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

override func startLoading() {
ProxyCannedProtocol.lastRequest = request
var headers = ["Content-Type": "application/json"]
for (key, value) in ProxyCannedProtocol.cannedHeaders {
headers[key] = value
}
let response = HTTPURLResponse(
url: request.url!,
statusCode: ProxyCannedProtocol.statusCode,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: ProxyCannedProtocol.cannedJSON)
client?.urlProtocolDidFinishLoading(self)
}

override func stopLoading() {}
}

/// Intercepts requests to gamehacking.org and returns empty HTML, preventing real network calls
/// during tests that exercise the direct-scrape fallback path.
private final class DirectScrapeBlockerProtocol: URLProtocol {
static var requestCount: Int = 0

override class func canInit(with request: URLRequest) -> Bool {
request.url?.host?.contains("gamehacking.org") ?? false
}

override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

override func startLoading() {
DirectScrapeBlockerProtocol.requestCount += 1
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "text/html"]
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data("<html><body></body></html>".utf8))
client?.urlProtocolDidFinishLoading(self)
}

override func stopLoading() {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,27 @@ public extension Defaults.Keys {
static let playerUsername = Key<String>("playerUsername", default: "")
}

// MARK: Cheats
public extension Defaults.Keys {
/// When `true`, the app first queries the cheat proxy endpoint for GameHacking.org
/// cheats instead of scraping the site directly. Falls back to direct scraping only
/// if the proxy is unreachable, returns a non-2xx status, or returns an empty result
/// without confirming success via `X-Proxy-Status: ok`. An empty result with
/// `X-Proxy-Status: ok` is treated as "no cheats found" and skips the fallback.
static let useCheatProxy = Key<Bool>("useCheatProxy", default: true)

/// Base URL of the deployed Provenance cheat proxy worker.
///
/// Behavior:
/// - If `useCheatProxy` is `false`, the proxy is not used regardless of this value.
/// - If `useCheatProxy` is `true` and this is non-empty, this value is used as the proxy base URL.
/// - If `useCheatProxy` is `true` and this is empty, a built-in compile-time default URL
/// (if configured) will be used; if no default is set the proxy path is skipped entirely.
///
/// See `Scripts/cheat-proxy/README.md` for deployment instructions.
static let cheatProxyURL = Key<String>("cheatProxyURL", default: "")
}

public enum ButtonPressEffect: String, Codable, Equatable, UserDefaultsRepresentable, Defaults.Serializable, CaseIterable {
case bubble = "bubble"
case ring = "ring"
Expand Down
Loading
Loading