diff --git a/components/databaseHandler.ts b/components/databaseHandler.ts
index e3ef0b80c..5374327ad 100644
--- a/components/databaseHandler.ts
+++ b/components/databaseHandler.ts
@@ -415,6 +415,7 @@ export async function setupFileStructure(joinFunc = join) {
"contracts",
joinFunc("userdata", "epicids"),
joinFunc("userdata", "steamids"),
+ joinFunc("userdata", "appleids"),
joinFunc("userdata", "users"),
joinFunc("userdata", "h1", "steamids"),
joinFunc("userdata", "h1", "epicids"),
diff --git a/components/generatedPeacockRequireTable.ts b/components/generatedPeacockRequireTable.ts
index ccbba4a04..1a2ecc553 100644
--- a/components/generatedPeacockRequireTable.ts
+++ b/components/generatedPeacockRequireTable.ts
@@ -20,6 +20,7 @@ import * as commandService from "./commandService"
import * as configSwizzleManager from "./configSwizzleManager"
import * as controller from "./controller"
import * as databaseHandler from "./databaseHandler"
+import * as delegation from "./delegation"
import * as entitlementStrategies from "./entitlementStrategies"
import * as eventHandler from "./eventHandler"
import * as evergreen from "./evergreen"
@@ -85,6 +86,7 @@ export default {
"@peacockproject/core/configSwizzleManager": configSwizzleManager,
"@peacockproject/core/controller": controller,
"@peacockproject/core/databaseHandler": databaseHandler,
+ "@peacockproject/core/delegation": delegation,
"@peacockproject/core/entitlementStrategies": entitlementStrategies,
"@peacockproject/core/eventHandler": eventHandler,
"@peacockproject/core/evergreen": evergreen,
diff --git a/components/index.ts b/components/index.ts
index d107a113b..5acf5e650 100644
--- a/components/index.ts
+++ b/components/index.ts
@@ -125,7 +125,16 @@ const app = express()
const baseDir = __dirname
app.use(function badPathRewritingMiddleware(req, _, next) {
- req.url = req.url.replaceAll("//", "/")
+ // rewrite every `//` to `/` that occurs before the query string
+ const qIdx = req.url.indexOf("?")
+
+ if (qIdx === -1) {
+ req.url = req.url.replaceAll("//", "/")
+ } else {
+ req.url =
+ req.url.slice(0, qIdx).replaceAll("//", "/") + req.url.slice(qIdx)
+ }
+
next()
})
app.use(loggingMiddleware)
@@ -211,31 +220,39 @@ app.get(
"pc-prod_6"
}
- if (req.query.issuer === STEAM_NAMESPACE_2021) {
- config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
- "steam-prod_8"
+ switch (req.query.issuer) {
+ case STEAM_NAMESPACE_2021:
+ config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
+ "steam-prod_8"
+ break
+ case "https://appleid.apple.com":
+ config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
+ "apple-prod_8"
+ break
}
- if (req.params.audience === "scpc-prod") {
+ switch (req.params.audience) {
// sniper challenge is a different game/audience
- config.Versions[0].Name = "scpc-prod"
- config.Versions[0].GAME_VER = "7.3.0"
- config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
- "scpc-prod"
+ case "scpc-prod":
+ config.Versions[0].Name = "scpc-prod"
+ config.Versions[0].GAME_VER = "7.3.0"
+ config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
+ "scpc-prod"
+ break
+ case "macos-prod":
+ config.Versions[0].Name = "macos-prod"
+ break
+ case "macossteam-prod":
+ config.Versions[0].Name = "macossteam-prod"
+ break
}
config.Versions[0].ISSUER_ID = req.query.issuer || "*"
-
config.Versions[0].SERVER_VER.Metrics.MetricsServerHost = `${proto}://${serverhost}`
-
config.Versions[0].SERVER_VER.Authentication.AuthenticationHost = `${proto}://${serverhost}`
-
config.Versions[0].SERVER_VER.Configuration.Url = `${proto}://${serverhost}/files/onlineconfig.json`
-
config.Versions[0].SERVER_VER.Configuration.AgreementUrl = `${proto}://${serverhost}/files/privacypolicy/hm3/privacypolicy.json`
-
config.Versions[0].SERVER_VER.Resources.ResourcesServicePath = `${proto}://${serverhost}/files`
-
config.Versions[0].SERVER_VER.GlobalAuthentication.AuthenticationHost = `${proto}://${serverhost}`
res.json(config)
diff --git a/components/oauthToken.ts b/components/oauthToken.ts
index c2234dc50..6b7ef671e 100644
--- a/components/oauthToken.ts
+++ b/components/oauthToken.ts
@@ -49,9 +49,17 @@ export const JWT_SECRET = PEACOCK_DEV
: randomBytes(32).toString("hex")
export type OAuthTokenBody = {
- grant_type: "external_steam" | "external_epic" | "refresh_token"
+ grant_type:
+ | "external_steam"
+ | "external_epic"
+ | "external_apple"
+ | "refresh_token"
steam_userid?: string
epic_userid?: string
+ apple_userid?: string
+ apple_refreshtoken?: string
+ device_os?: string
+ device_id?: string
access_token: string
pId?: string
locale: string
@@ -85,78 +93,93 @@ export async function handleOAuthToken(
notBefore: -60000,
expiresIn: 6000,
issuer: "auth.hitman.io",
- audience: isScpc ? "scpc-prod" : "pc_prod_8",
+ audience: (() => {
+ if (isScpc) return "scpc-prod"
+ if (req.body.grant_type === "external_apple") return "macos-prod"
+ return "pc_prod_8"
+ })(),
noTimestamp: true,
}
- let external_platform: "steam" | "epic",
+ let external_platform: "steam" | "epic" | "apple",
external_userid: string,
- external_users_folder: "steamids" | "epicids",
+ external_users_folder: "steamids" | "epicids" | "appleids",
external_appid: string
- if (req.body.grant_type === "external_steam") {
- if (!/^\d{1,20}$/.test(req.body.steam_userid || "")) {
- return error400 // invalid steam user id
- }
-
- external_platform = "steam"
- external_userid = req.body.steam_userid || ""
- external_users_folder = "steamids"
- external_appid = req.body.steam_appid
- } else if (req.body.grant_type === "external_epic") {
- if (!/^[\da-f]{32}$/.test(req.body.epic_userid || "")) {
- return error400 // invalid epic user id
- }
+ switch (req.body.grant_type) {
+ case "external_steam":
+ if (!/^\d{1,20}$/.test(req.body.steam_userid || "")) {
+ return error400 // invalid steam user id
+ }
- const epic_token = decode(
- req.body.access_token.replace(/^eg1~/, ""),
- ) as {
- appid: string
- app: string
- }
+ external_platform = "steam"
+ external_userid = req.body.steam_userid || ""
+ external_users_folder = "steamids"
+ external_appid = req.body.steam_appid
+ break
+ case "external_epic": {
+ if (!/^[\da-f]{32}$/.test(req.body.epic_userid || "")) {
+ return error400 // invalid epic user id
+ }
- if (!epic_token || !(epic_token.appid || epic_token.app)) {
- return error400 // invalid epic access token
- }
+ const epic_token = decode(
+ req.body.access_token.replace(/^eg1~/, ""),
+ ) as {
+ appid: string
+ app: string
+ }
- external_appid = epic_token.appid || epic_token.app
- external_platform = "epic"
- external_userid = req.body.epic_userid || ""
- external_users_folder = "epicids"
- } else if (req.body.grant_type === "refresh_token") {
- // send back the token from the request (re-signed so the timestamps update)
- extractToken(req) // init req.jwt
- // remove signOptions from existing jwt
- // @ts-expect-error Non-optional, we're reassigning.
- delete req.jwt.nbf // notBefore
- // @ts-expect-error Non-optional, we're reassigning.
- delete req.jwt.exp // expiresIn
- // @ts-expect-error Non-optional, we're reassigning.
- delete req.jwt.iss // issuer
- // @ts-expect-error Non-optional, we're reassigning.
- delete req.jwt.aud // audience
-
- if (!isScpc) {
- if (userAuths.has(req.jwt.unique_name)) {
- userAuths
- .get(req.jwt.unique_name)!
- ._doRefresh()
- .then(() => undefined)
- .catch(() => {
- log(LogLevel.WARN, "Failed authentication refresh.")
- userAuths.get(req.jwt.unique_name)!.initialized = false
- })
+ if (!epic_token || !(epic_token.appid || epic_token.app)) {
+ return error400 // invalid epic access token
}
- }
- return {
- access_token: sign(req.jwt, JWT_SECRET, signOptions),
- token_type: "bearer",
- expires_in: 5000,
- refresh_token: randomUUID(),
+ external_appid = epic_token.appid || epic_token.app
+ external_platform = "epic"
+ external_userid = req.body.epic_userid || ""
+ external_users_folder = "epicids"
+ break
}
- } else {
- return error406 // unsupported auth method
+ case "external_apple":
+ external_platform = "apple"
+ external_userid = req.body.apple_userid || ""
+ external_users_folder = "appleids"
+ external_appid = "apple"
+ break
+ case "refresh_token":
+ // send back the token from the request (re-signed so the timestamps update)
+ extractToken(req) // init req.jwt
+ // remove signOptions from existing jwt
+ // @ts-expect-error Non-optional, we're reassigning.
+ delete req.jwt.nbf // notBefore
+ // @ts-expect-error Non-optional, we're reassigning.
+ delete req.jwt.exp // expiresIn
+ // @ts-expect-error Non-optional, we're reassigning.
+ delete req.jwt.iss // issuer
+ // @ts-expect-error Non-optional, we're reassigning.
+ delete req.jwt.aud // audience
+
+ if (!isScpc) {
+ if (userAuths.has(req.jwt.unique_name)) {
+ userAuths
+ .get(req.jwt.unique_name)!
+ ._doRefresh()
+ .then(() => undefined)
+ .catch(() => {
+ log(LogLevel.WARN, "Failed authentication refresh.")
+ userAuths.get(req.jwt.unique_name)!.initialized =
+ false
+ })
+ }
+ }
+
+ return {
+ access_token: sign(req.jwt, JWT_SECRET, signOptions),
+ token_type: "bearer",
+ expires_in: 5000,
+ refresh_token: randomUUID(),
+ }
+ default:
+ return error406 // unsupported auth method
}
if (req.body.pId && !uuidRegex.test(req.body.pId)) {
@@ -165,7 +188,8 @@ export async function handleOAuthToken(
const isHitman3 =
external_appid === "fghi4567xQOCheZIin0pazB47qGUvZw4" ||
- external_appid === STEAM_NAMESPACE_2021
+ external_appid === STEAM_NAMESPACE_2021 ||
+ external_platform === "apple"
let gameVersion: GameVersion = "h1"
@@ -253,6 +277,8 @@ export async function handleOAuthToken(
userData.SteamId = req.body.steam_userid!
} else if (external_platform === "epic") {
userData.EpicId = req.body.epic_userid!
+ } else if (external_platform === "apple") {
+ userData.AppleId = req.body.apple_userid!
}
if (Object.hasOwn(userData.Extensions, "inventory")) {
@@ -292,6 +318,9 @@ export async function handleOAuthToken(
gameVersion,
STEAM_NAMESPACE_2021,
).get(req.body.pId!)
+ } else if (external_platform === "apple") {
+ // TODO
+ return []
} else {
log(LogLevel.ERROR, "Unsupported platform.")
return []
diff --git a/components/types/types.ts b/components/types/types.ts
index 6ab27f4e3..5a6927fc1 100644
--- a/components/types/types.ts
+++ b/components/types/types.ts
@@ -482,7 +482,10 @@ export type UserProfile = {
steam?: string
gog?: string
xbox?: string
+ /** @deprecated */
stadia?: string
+ apple?: string
+ nintendo?: string
}
Extensions: {
/**
@@ -574,6 +577,7 @@ export type UserProfile = {
DevId: string | null
SteamId: string | null
EpicId: string | null
+ AppleId?: string | null
NintendoId: string | null
XboxLiveId: string | null
PSNAccountId: string | null
diff --git a/patcher-darwin/.idea/editor.xml b/patcher-darwin/.idea/editor.xml
index 8d0e15e94..169510248 100644
--- a/patcher-darwin/.idea/editor.xml
+++ b/patcher-darwin/.idea/editor.xml
@@ -208,7 +208,7 @@
-
+
diff --git a/patcher-darwin/src/main.cpp b/patcher-darwin/src/main.cpp
index b7bcd487a..35727725f 100644
--- a/patcher-darwin/src/main.cpp
+++ b/patcher-darwin/src/main.cpp
@@ -1,3 +1,4 @@
+// ReSharper disable CppUseStructuredBinding
#include "log.h"
#include "patcher.h"
@@ -9,6 +10,38 @@
#include
#include
+struct CliOption {
+ const char *flag;
+ const char *description;
+};
+
+constexpr CliOption kCliOptions[] = {
+ {"--headless", "Patch once and exit"},
+ {"--domain ", "Server domain (default: 127.0.0.1)"},
+ {"--dont-use-http", "Use HTTPS instead of HTTP"},
+ {"--non-optional-dynamic-resources",
+ "Don't try to make dynamic resources optional"},
+ {"--disable-dynamic-resources",
+ "Don't try to make dynamic resources mandatory"},
+ {"--help", "Show this help message"},
+};
+
+constexpr size_t cstr_length(const char *s) {
+ size_t n = 0;
+ while (s[n] != '\0') ++n;
+ return n;
+}
+
+/** Find the longest flag's length, so we know how many spaces each entry needs to be padded by. */
+constexpr size_t kFlagWidth = [] {
+ size_t max = 0;
+ for (auto& option : kCliOptions) {
+ const size_t len = cstr_length(option.flag);
+ if (len > max) max = len;
+ }
+ return max;
+}();
+
static void print_usage() {
printf("Peacock Patcher for macOS\n");
printf("Patches HITMAN World of Assassination to use a custom server.\n");
@@ -16,14 +49,10 @@ static void print_usage() {
printf("Usage: PeacockPatcher [options]\n");
printf("\n");
printf("Options:\n");
- printf(" --headless Patch once and exit\n");
- printf(" --domain Server domain (default: "
- "127.0.0.1)\n");
- printf(" --dont-use-http Use HTTPS instead of HTTP\n");
- printf(
- " --non-optional-dynamic-resources Don't try to make dynamic"
- "resources optional\n");
- printf(" --help Show this help message\n");
+
+ for (const auto& option : kCliOptions) {
+ printf(" %-*s %s\n", static_cast(kFlagWidth), option.flag, option.description);
+ }
}
int main(int argc, char *argv[]) {
@@ -39,6 +68,7 @@ int main(int argc, char *argv[]) {
bool headless = false;
std::string domain = "127.0.0.1";
bool use_http = true;
+ bool enable_dynres = true;
bool optional_dynres = true;
for (int i = 1; i < argc; i++) {
@@ -55,6 +85,8 @@ int main(int argc, char *argv[]) {
use_http = false;
} else if (strcmp(argv[i], "--non-optional-dynamic-resources") == 0) {
optional_dynres = false;
+ } else if (strcmp(argv[i], "--disable-dynamic-resources") == 0) {
+ enable_dynres = false;
} else if (strcmp(argv[i], "--help") == 0) {
print_usage();
return 0;
@@ -71,7 +103,7 @@ int main(int argc, char *argv[]) {
options.disable_cert_pinning = true;
options.always_send_auth_header = true;
options.set_custom_config_domain = true;
- options.enable_dynamic_resources = true;
+ options.enable_dynamic_resources = enable_dynres;
options.disable_force_offline_on_failed_dynres = optional_dynres;
peacock::Patcher patcher;