@sendoracloud/sdk-react-native-gaming 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — Game Center / Play Games native module (split out of the main SDK)
4
+
5
+ First release. The Game Center (iOS GameKit) + Play Games (Android PGS v2) native
6
+ Expo module + its config plugin, extracted from `@sendoracloud/sdk-react-native`
7
+ 1.23.0 into an **optional companion** so non-gaming apps don't pull the Android
8
+ `play-services-games-v2` dependency.
9
+
10
+ - `expo-module.config.json` + `ios/SendoraGameAuthModule.swift` (GameKit
11
+ `fetchItems(forIdentityVerificationSignature:)`) + podspec.
12
+ - `android/…/SendoraGameAuthModule.kt` (PGS v2 `requestServerSideAccess`) +
13
+ `build.gradle` (`play-services-games-v2:21.0.0`).
14
+ - `app.plugin.js` — opt-in `{ gameCenter }` (iOS entitlement) +
15
+ `{ playGamesWebClientId, playGamesProjectId }` (Android `APP_ID` string resource
16
+ + web-client-id meta-data). Pure transforms unit-tested (`test/plugin.test.ts`).
17
+
18
+ Registers `SendoraGameAuth` on `globalThis.expo.modules`; the main SDK's
19
+ `auth.signInWithGameCenter()` / `auth.signInWithPlayGames()` (no-arg) read it.
20
+ Requires `@sendoracloud/sdk-react-native >= 1.23.0` + a Dev Client.
21
+
22
+ **On-device smoke (real Game Center + Play Games accounts) is the release gate.**
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @sendoracloud/sdk-react-native-gaming
2
+
3
+ Game Center (iOS) + Play Games (Android) native module for
4
+ [`@sendoracloud/sdk-react-native`](https://www.npmjs.com/package/@sendoracloud/sdk-react-native).
5
+
6
+ Install this **optional companion** only if your app uses email-less gaming
7
+ sign-in. It ships the native code (GameKit / Play Games) that lets the main SDK's
8
+ `auth.signInWithGameCenter()` / `auth.signInWithPlayGames()` run with **no
9
+ arguments** — the SDK fetches the provider identity payload for you.
10
+
11
+ Non-gaming apps should **not** install this package: it adds the Android
12
+ `play-services-games-v2` dependency. The main SDK works fine without it (the
13
+ no-arg calls throw a clear "install this package" error; the explicit-payload
14
+ overloads still work everywhere).
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npx expo install @sendoracloud/sdk-react-native @sendoracloud/sdk-react-native-gaming
20
+ ```
21
+
22
+ Needs a **Dev Client** — `npx expo prebuild` (or EAS Build). It does **not** work
23
+ in Expo Go (native code).
24
+
25
+ ## Configure (app.json / app.config.js)
26
+
27
+ ```jsonc
28
+ {
29
+ "plugins": [
30
+ ["@sendoracloud/sdk-react-native-gaming", {
31
+ "gameCenter": true, // iOS: Game Center capability
32
+ "playGamesWebClientId": "<web-oauth-client-id>.apps.googleusercontent.com", // Android: from Google Cloud (Web app OAuth)
33
+ "playGamesProjectId": "1234567890" // Android: Play Games project id (Play Console)
34
+ }]
35
+ ]
36
+ }
37
+ ```
38
+
39
+ - `gameCenter: true` → adds the `com.apple.developer.game-center` entitlement (iOS).
40
+ - `playGamesWebClientId` + `playGamesProjectId` (**both required**) → writes the
41
+ mandatory `com.google.android.gms.games.APP_ID` and the Web OAuth client id the
42
+ native module reads for `requestServerSideAccess` (Android).
43
+
44
+ Then `npx expo prebuild` and rebuild.
45
+
46
+ ## Use (from the main SDK)
47
+
48
+ ```ts
49
+ import { Sendora } from "@sendoracloud/sdk-react-native";
50
+
51
+ // iOS — drives the Game Center sheet + verifies natively:
52
+ await Sendora.auth.signInWithGameCenter();
53
+ // Android — drives Play Games sign-in + returns a server auth code:
54
+ await Sendora.auth.signInWithPlayGames();
55
+ // Link an anonymous device in place (keep the same user id):
56
+ await Sendora.auth.signInWithGameCenter({ link: true });
57
+ ```
58
+
59
+ Configure the matching provider in the Sendora dashboard (Auth Service → Settings
60
+ → Gaming sign-in): the Game Center bundle-id allow-list, or the Play Games **Web**
61
+ OAuth client id + secret.
62
+
63
+ ## Notes
64
+
65
+ - Player-keyed + **email-less**: Game Center = stable `teamPlayerID`; Play Games =
66
+ stable per-game `playerId`. No email or verified name.
67
+ - The native module registers as `SendoraGameAuth` on `globalThis.expo.modules`;
68
+ the main SDK reads it zero-dependency.
@@ -0,0 +1,25 @@
1
+ // Sendora Cloud — Play Games sign-in native module (Expo Modules API).
2
+ //
3
+ // `expo-module-gradle-plugin` (bundled with expo-modules-core) wires the
4
+ // ExpoModulesCore dependency, Kotlin, compile/min/target SDK (inherited from the
5
+ // host app), namespace publication, and JSI registration. We only add our own
6
+ // Play Games dependency + namespace.
7
+ //
8
+ // NOTE (app-size): `play-services-games-v2` is compiled into EVERY Android app
9
+ // that installs this SDK and runs `expo prebuild` — including non-gaming apps.
10
+ // It is a modest Google Play Services module (heavily R8-shrinkable in release).
11
+ // The runtime code only runs when `signInWithPlayGames()` is actually called.
12
+ apply plugin: 'com.android.library'
13
+ apply plugin: 'expo-module-gradle-plugin'
14
+
15
+ group = 'cloud.sendora.gameauth'
16
+ version = '1.0.0'
17
+
18
+ android {
19
+ namespace 'cloud.sendora.gameauth'
20
+ }
21
+
22
+ dependencies {
23
+ // Google Play Games Services v2 sign-in (GamesSignInClient.requestServerSideAccess).
24
+ implementation 'com.google.android.gms:play-services-games-v2:21.0.0'
25
+ }
@@ -0,0 +1 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,118 @@
1
+ // Sendora Cloud — Play Games sign-in native module (Expo Modules API).
2
+ //
3
+ // Exposes ONE async function to JS, `fetchServerAuthCode()`, which:
4
+ // 1. reads the Web OAuth client id from the manifest meta-data written by the
5
+ // Sendora Expo config plugin (`com.sendoracloud.play_games_web_client_id`),
6
+ // 2. ensures the player is signed in to Play Games (v2 GamesSignInClient),
7
+ // 3. calls `requestServerSideAccess(webClientId, false)` and returns the
8
+ // one-time `serverAuthCode` the Sendora backend exchanges for the stable
9
+ // per-game `playerId`.
10
+ //
11
+ // The JS SDK's `auth.signInWithPlayGames()` (no args) reads this module off
12
+ // `globalThis.expo.modules.SendoraGameAuth` (zero-dependency, Metro-safe) and
13
+ // POSTs `{ serverAuthCode }` to `/auth-service/login/play-games`. Email-less,
14
+ // player-keyed.
15
+ //
16
+ // Requires the `com.google.android.gms.games.APP_ID` meta-data (the numeric Play
17
+ // Games project id) — wired by the config plugin's `{ playGamesProjectId,
18
+ // playGamesWebClientId }` props. Without it, Play Games init throws.
19
+ package cloud.sendora.gameauth
20
+
21
+ import android.app.Activity
22
+ import android.content.pm.PackageManager
23
+ import com.google.android.gms.games.GamesSignInClient
24
+ import com.google.android.gms.games.PlayGames
25
+ import com.google.android.gms.games.PlayGamesSdk
26
+ import expo.modules.kotlin.Promise
27
+ import expo.modules.kotlin.modules.Module
28
+ import expo.modules.kotlin.modules.ModuleDefinition
29
+
30
+ private const val META_WEB_CLIENT_ID = "com.sendoracloud.play_games_web_client_id"
31
+
32
+ class SendoraGameAuthModule : Module() {
33
+ override fun definition() = ModuleDefinition {
34
+ Name("SendoraGameAuth")
35
+
36
+ AsyncFunction("fetchServerAuthCode") { promise: Promise ->
37
+ val activity = appContext.currentActivity
38
+ if (activity == null) {
39
+ promise.reject("E_NO_ACTIVITY", "No current Activity for Play Games sign-in.", null)
40
+ return@AsyncFunction
41
+ }
42
+
43
+ val webClientId = readMetaData(activity, META_WEB_CLIENT_ID)
44
+ if (webClientId.isNullOrBlank()) {
45
+ promise.reject(
46
+ "E_PLAY_GAMES_CONFIG",
47
+ "Play Games web client id not configured. Add the Sendora config plugin with " +
48
+ "{ playGamesWebClientId, playGamesProjectId } and rebuild the app.",
49
+ null,
50
+ )
51
+ return@AsyncFunction
52
+ }
53
+
54
+ // v2 must be initialized before the sign-in client is used. Idempotent;
55
+ // reads the mandatory APP_ID meta-data (throws if it is missing/invalid).
56
+ try {
57
+ PlayGamesSdk.initialize(activity.application)
58
+ } catch (e: Throwable) {
59
+ promise.reject(
60
+ "E_PLAY_GAMES_INIT",
61
+ "Play Games failed to initialize — is the com.google.android.gms.games.APP_ID meta-data set?",
62
+ e,
63
+ )
64
+ return@AsyncFunction
65
+ }
66
+
67
+ val client = PlayGames.getGamesSignInClient(activity)
68
+ client.isAuthenticated().addOnCompleteListener { authTask ->
69
+ val authed = authTask.isSuccessful && authTask.result.isAuthenticated
70
+ if (authed) {
71
+ requestServerAuthCode(client, webClientId, promise)
72
+ } else {
73
+ client.signIn().addOnCompleteListener { signInTask ->
74
+ if (signInTask.isSuccessful && signInTask.result.isAuthenticated) {
75
+ requestServerAuthCode(client, webClientId, promise)
76
+ } else {
77
+ promise.reject(
78
+ "E_PLAY_GAMES_SIGNIN",
79
+ "Google Play Games sign-in failed or was cancelled.",
80
+ signInTask.exception,
81
+ )
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ private fun requestServerAuthCode(client: GamesSignInClient, webClientId: String, promise: Promise) {
90
+ // 2-arg overload → Task<String>: the one-time server auth code. `false` =
91
+ // online access only (the backend needs no long-lived refresh token).
92
+ client.requestServerSideAccess(webClientId, false).addOnCompleteListener { task ->
93
+ // `task.result` THROWS if the task failed — check isSuccessful first.
94
+ val code = if (task.isSuccessful) task.result else null
95
+ if (!code.isNullOrBlank()) {
96
+ promise.resolve(mapOf("serverAuthCode" to code))
97
+ } else {
98
+ promise.reject(
99
+ "E_PLAY_GAMES_AUTHCODE",
100
+ "Failed to obtain a Play Games server auth code.",
101
+ task.exception,
102
+ )
103
+ }
104
+ }
105
+ }
106
+
107
+ private fun readMetaData(activity: Activity, key: String): String? {
108
+ return try {
109
+ val ai = activity.packageManager.getApplicationInfo(
110
+ activity.packageName,
111
+ PackageManager.GET_META_DATA,
112
+ )
113
+ ai.metaData?.getString(key)?.trim()
114
+ } catch (e: Exception) {
115
+ null
116
+ }
117
+ }
118
+ }
package/app.plugin.js ADDED
@@ -0,0 +1,97 @@
1
+ // Sendora gaming Expo config plugin — wires the platform config the Game Center
2
+ // (iOS) / Play Games (Android) native module needs, so games don't hand-edit
3
+ // entitlements / manifests.
4
+ //
5
+ // Usage in app.json / app.config.js:
6
+ // { "plugins": [["@sendoracloud/sdk-react-native-gaming", {
7
+ // "gameCenter": true,
8
+ // "playGamesWebClientId": "<web-oauth-client-id>.apps.googleusercontent.com",
9
+ // "playGamesProjectId": "1234567890"
10
+ // }]] }
11
+ //
12
+ // - iOS `{ gameCenter: true }` → `com.apple.developer.game-center` entitlement.
13
+ // - Android `{ playGamesWebClientId, playGamesProjectId }` → the mandatory
14
+ // `com.google.android.gms.games.APP_ID` (as a `game_services_project_id` string
15
+ // resource) + the `com.sendoracloud.play_games_web_client_id` meta-data the
16
+ // native module reads for `requestServerSideAccess`. BOTH ids are required.
17
+ //
18
+ // SAFE / backward-compatible: opt-in + build-time only (runs at `expo prebuild`),
19
+ // MERGE/dedupe (an app that already declared these keeps them);
20
+ // `createRunOncePlugin` guards double-apply. ESM (the package is `type: module`)
21
+ // → Expo SDK 50+. Bug-prone transforms live in ./plugin/apply.js (pure,
22
+ // unit-tested); this file wires them into the Expo mod pipeline.
23
+ import {
24
+ withEntitlementsPlist,
25
+ withStringsXml,
26
+ withAndroidManifest,
27
+ AndroidConfig,
28
+ createRunOncePlugin,
29
+ WarningAggregator,
30
+ } from "@expo/config-plugins";
31
+ import { createRequire } from "node:module";
32
+ import {
33
+ resolveGameProps,
34
+ setGameCenterEntitlement,
35
+ upsertStringResource,
36
+ addManifestMetaData,
37
+ } from "./plugin/apply.js";
38
+
39
+ const pkg = createRequire(import.meta.url)("./package.json");
40
+
41
+ const PLAY_GAMES_PROJECT_ID_RES = "game_services_project_id";
42
+ const PLAY_GAMES_APP_ID_META = "com.google.android.gms.games.APP_ID";
43
+ const PLAY_GAMES_WEB_CLIENT_ID_META = "com.sendoracloud.play_games_web_client_id";
44
+
45
+ // iOS: Game Center capability entitlement (required for identity verification).
46
+ function withGameCenter(config) {
47
+ return withEntitlementsPlist(config, (cfg) => {
48
+ cfg.modResults = setGameCenterEntitlement(cfg.modResults);
49
+ return cfg;
50
+ });
51
+ }
52
+
53
+ // Android: the mandatory Play Games APP_ID (a string resource referenced by the
54
+ // games meta-data) + the Web OAuth client id the native module reads.
55
+ function withPlayGames(config, playGames) {
56
+ config = withStringsXml(config, (cfg) => {
57
+ cfg.modResults = upsertStringResource(
58
+ cfg.modResults,
59
+ PLAY_GAMES_PROJECT_ID_RES,
60
+ playGames.projectId,
61
+ { translatable: false },
62
+ );
63
+ return cfg;
64
+ });
65
+ config = withAndroidManifest(config, (cfg) => {
66
+ const app = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults);
67
+ addManifestMetaData(app, PLAY_GAMES_APP_ID_META, `@string/${PLAY_GAMES_PROJECT_ID_RES}`);
68
+ addManifestMetaData(app, PLAY_GAMES_WEB_CLIENT_ID_META, playGames.webClientId);
69
+ return cfg;
70
+ });
71
+ return config;
72
+ }
73
+
74
+ const withSendoraGaming = (config, props) => {
75
+ const game = resolveGameProps(props);
76
+ if (!game.gameCenter && !game.playGames) {
77
+ const partial = props && (props.playGamesWebClientId || props.playGamesProjectId);
78
+ WarningAggregator.addWarningIOS(
79
+ pkg.name,
80
+ partial
81
+ ? "Play Games not wired — set BOTH { playGamesWebClientId } and { playGamesProjectId }."
82
+ : "No gaming props configured — pass { gameCenter: true } and/or { playGamesWebClientId, playGamesProjectId }.",
83
+ );
84
+ return config;
85
+ }
86
+ if (game.gameCenter) config = withGameCenter(config);
87
+ if (game.playGames) config = withPlayGames(config, game.playGames);
88
+ else if (props && (props.playGamesWebClientId || props.playGamesProjectId)) {
89
+ WarningAggregator.addWarningAndroid(
90
+ pkg.name,
91
+ "Play Games not wired — set BOTH { playGamesWebClientId } and { playGamesProjectId }.",
92
+ );
93
+ }
94
+ return config;
95
+ };
96
+
97
+ export default createRunOncePlugin(withSendoraGaming, pkg.name, pkg.version);
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["SendoraGameAuthModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["cloud.sendora.gameauth.SendoraGameAuthModule"]
8
+ }
9
+ }
@@ -0,0 +1,33 @@
1
+ # Sendora Cloud — Game Center identity native module (Expo Modules API).
2
+ #
3
+ # Autolinked by Expo when the host app runs `expo prebuild` / `pod install`
4
+ # (discovered via ../expo-module.config.json). GameKit is a system framework —
5
+ # no third-party pods, no app-size cost on iOS. The module is OPTIONAL at
6
+ # runtime: apps that never call `signInWithGameCenter()` never touch it.
7
+ require 'json'
8
+
9
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
10
+
11
+ Pod::Spec.new do |s|
12
+ s.name = 'SendoraGameAuth'
13
+ s.version = package['version']
14
+ s.summary = 'Game Center identity verification for Sendora Cloud auth.'
15
+ s.description = package['description']
16
+ s.license = package['license']
17
+ s.author = 'Sendora Cloud'
18
+ s.homepage = package['homepage']
19
+ s.platforms = { :ios => '15.1', :tvos => '15.1' }
20
+ s.swift_version = '5.9'
21
+ s.source = { git: 'https://github.com/sendoracloud/sdk-react-native' }
22
+ s.static_framework = true
23
+
24
+ s.dependency 'ExpoModulesCore'
25
+
26
+ # GameKit is linked implicitly via `import GameKit` in the Swift source.
27
+ s.pod_target_xcconfig = {
28
+ 'DEFINES_MODULE' => 'YES',
29
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
30
+ }
31
+
32
+ s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
33
+ end
@@ -0,0 +1,128 @@
1
+ // Sendora Cloud — Game Center identity native module (Expo Modules API).
2
+ //
3
+ // Exposes ONE async function to JS, `fetchGameCenterIdentity()`, which:
4
+ // 1. ensures the local player is authenticated (drives the Game Center
5
+ // sign-in sheet via `authenticateHandler` if needed), then
6
+ // 2. calls `GKLocalPlayer.local.fetchItems(forIdentityVerificationSignature:)`
7
+ // — the current, non-deprecated identity API — and returns the exact
8
+ // payload the Sendora backend verifies:
9
+ // { publicKeyUrl, signature(b64), salt(b64), timestamp(ms), teamPlayerId, bundleId }
10
+ //
11
+ // The JS SDK's `auth.signInWithGameCenter()` (no args) reads this module off
12
+ // `globalThis.expo.modules.SendoraGameAuth` (zero-dependency, Metro-safe) and
13
+ // POSTs the result to `/auth-service/login/game-center`. Email-less, player-keyed
14
+ // — `teamPlayerId` is the stable per-developer-Team account key; there is no
15
+ // email or verified name.
16
+ //
17
+ // Requires the Game Center capability (`com.apple.developer.game-center`
18
+ // entitlement) on a Game Center-enabled App ID — wired by the Sendora Expo
19
+ // config plugin's `{ gameCenter: true }` prop.
20
+ import ExpoModulesCore
21
+ import GameKit
22
+ import UIKit
23
+
24
+ public class SendoraGameAuthModule: Module {
25
+ public func definition() -> ModuleDefinition {
26
+ Name("SendoraGameAuth")
27
+
28
+ // Runs on the main queue so the GameKit auth flow + view-controller
29
+ // presentation are main-thread safe (Expo AsyncFunctions default to a
30
+ // background queue).
31
+ AsyncFunction("fetchGameCenterIdentity") { (promise: Promise) in
32
+ let local = GKLocalPlayer.local
33
+
34
+ // Already authenticated (the common case once the app has signed in
35
+ // to Game Center) → straight to the signature.
36
+ if local.isAuthenticated {
37
+ Self.fetchItems(local, promise)
38
+ return
39
+ }
40
+
41
+ // Not authenticated — drive the Game Center auth flow. Assigning the
42
+ // handler starts it; it can fire MORE THAN ONCE (once to hand back a
43
+ // sign-in view controller to present, again when auth resolves), so a
44
+ // `settled` guard ensures the JS promise is resolved/rejected exactly
45
+ // once. `authenticateHandler` is a GameKit singleton — the JS SDK
46
+ // serializes all auth ops (single-flight), so two SDK-driven sign-ins
47
+ // can't race to overwrite it; an app that ALSO sets its own handler
48
+ // should let the SDK own Game Center auth when using this no-arg path.
49
+ var settled = false
50
+ local.authenticateHandler = { viewController, error in
51
+ if settled { return }
52
+
53
+ if let error = error {
54
+ settled = true
55
+ promise.reject("E_GAME_CENTER_AUTH", error.localizedDescription)
56
+ return
57
+ }
58
+
59
+ if let viewController = viewController {
60
+ // Present the Game Center sign-in sheet from the TOP-most presented VC
61
+ // — presenting on the root VC throws if a modal (an RN modal, an auth
62
+ // sheet) is already up.
63
+ guard let top = Self.topViewController() else {
64
+ settled = true
65
+ promise.reject("E_NO_VIEW_CONTROLLER", "No view controller available to present the Game Center sign-in sheet.")
66
+ return
67
+ }
68
+ top.present(viewController, animated: true)
69
+ return // wait for the handler to fire again once auth resolves
70
+ }
71
+
72
+ // viewController == nil && error == nil → authentication resolved.
73
+ guard local.isAuthenticated else {
74
+ settled = true
75
+ promise.reject("E_GAME_CENTER_CANCELLED", "Game Center sign-in was cancelled or is unavailable.")
76
+ return
77
+ }
78
+ settled = true
79
+ Self.fetchItems(local, promise)
80
+ }
81
+ }.runOnQueue(.main)
82
+ }
83
+
84
+ /// Fetch the identity-verification items and resolve the JSON-safe payload.
85
+ private static func fetchItems(_ local: GKLocalPlayer, _ promise: Promise) {
86
+ local.fetchItems(forIdentityVerificationSignature: { publicKeyURL, signature, salt, timestamp, error in
87
+ if let error = error {
88
+ promise.reject("E_GAME_CENTER_FETCH", error.localizedDescription)
89
+ return
90
+ }
91
+ guard let publicKeyURL = publicKeyURL, let signature = signature, let salt = salt else {
92
+ promise.reject("E_GAME_CENTER_FETCH", "Game Center returned an incomplete identity signature.")
93
+ return
94
+ }
95
+ // timestamp is ms since epoch (~1.7e12) — well under 2^53, so it is exact
96
+ // as a JS number (Double). The backend schema expects `timestamp: number`.
97
+ promise.resolve([
98
+ "publicKeyUrl": publicKeyURL.absoluteString,
99
+ "signature": signature.base64EncodedString(),
100
+ "salt": salt.base64EncodedString(),
101
+ "timestamp": Double(timestamp),
102
+ "teamPlayerId": local.teamPlayerID,
103
+ "bundleId": Bundle.main.bundleIdentifier ?? "",
104
+ ])
105
+ })
106
+ }
107
+
108
+ /// The foreground window scene's root view controller (main thread).
109
+ private static func rootViewController() -> UIViewController? {
110
+ return UIApplication.shared.connectedScenes
111
+ .compactMap { $0 as? UIWindowScene }
112
+ .first(where: { $0.activationState == .foregroundActive })?
113
+ .keyWindow?.rootViewController
114
+ ?? UIApplication.shared.connectedScenes
115
+ .compactMap { ($0 as? UIWindowScene)?.keyWindow }
116
+ .first?.rootViewController
117
+ }
118
+
119
+ /// Walk the modal presentation chain to the top-most VC — the only VC that can
120
+ /// present another. Presenting on a VC that is already presenting throws.
121
+ private static func topViewController() -> UIViewController? {
122
+ var top = rootViewController()
123
+ while let presented = top?.presentedViewController, !presented.isBeingDismissed {
124
+ top = presented
125
+ }
126
+ return top
127
+ }
128
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@sendoracloud/sdk-react-native-gaming",
3
+ "version": "1.0.0",
4
+ "description": "Game Center (iOS) + Play Games (Android) native module for @sendoracloud/sdk-react-native — enables the no-arg auth.signInWithGameCenter() / auth.signInWithPlayGames(). Optional companion so non-gaming apps don't pull the Play Games dependency. Needs a Dev Client (EAS Build or `npx expo prebuild`).",
5
+ "type": "module",
6
+ "keywords": [
7
+ "sendora",
8
+ "react-native",
9
+ "expo",
10
+ "game-center",
11
+ "play-games",
12
+ "auth",
13
+ "gaming"
14
+ ],
15
+ "files": [
16
+ "expo-module.config.json",
17
+ "ios",
18
+ "android",
19
+ "app.plugin.js",
20
+ "plugin",
21
+ "README.md",
22
+ "CHANGELOG.md",
23
+ "LICENSE"
24
+ ],
25
+ "peerDependencies": {
26
+ "react-native": ">=0.70.0",
27
+ "@sendoracloud/sdk-react-native": ">=1.23.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "react-native": { "optional": true },
31
+ "@sendoracloud/sdk-react-native": { "optional": true }
32
+ },
33
+ "scripts": {
34
+ "test": "tsx test/plugin.test.ts"
35
+ },
36
+ "devDependencies": {
37
+ "tsx": "^4.21.0",
38
+ "typescript": "^5.0.0"
39
+ },
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/sendoracloud/sdk-react-native"
44
+ },
45
+ "homepage": "https://sendoracloud.com/sdks",
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "registry": "https://registry.npmjs.org"
49
+ }
50
+ }
@@ -0,0 +1,68 @@
1
+ // Pure, dependency-free helpers for the Sendora gaming Expo config plugin
2
+ // (../app.plugin.js). Extracted so the bug-prone logic is unit-testable WITHOUT
3
+ // `@expo/config-plugins` (which only exists in the consumer's Expo prebuild env).
4
+ //
5
+ // ESM — the package is `type: module`.
6
+ //
7
+ // The plugin writes the platform CONFIG the native module needs:
8
+ // iOS — the `com.apple.developer.game-center` entitlement.
9
+ // Android — the mandatory `com.google.android.gms.games.APP_ID` meta-data
10
+ // (as a string resource) + the Web OAuth client id meta-data the
11
+ // module reads for `requestServerSideAccess`.
12
+ // All opt-in: nothing is added unless the corresponding prop is set.
13
+
14
+ /** Normalize the gaming plugin props. Play Games needs BOTH ids to wire. */
15
+ export function resolveGameProps(props) {
16
+ const p = props || {};
17
+ const gameCenter = p.gameCenter === true;
18
+ const webClientId =
19
+ typeof p.playGamesWebClientId === "string" && p.playGamesWebClientId.trim()
20
+ ? p.playGamesWebClientId.trim()
21
+ : null;
22
+ const projectId =
23
+ typeof p.playGamesProjectId === "string" && p.playGamesProjectId.trim()
24
+ ? p.playGamesProjectId.trim()
25
+ : typeof p.playGamesProjectId === "number"
26
+ ? String(p.playGamesProjectId)
27
+ : null;
28
+ return { gameCenter, playGames: webClientId && projectId ? { webClientId, projectId } : null };
29
+ }
30
+
31
+ /** iOS: enable the Game Center capability (`com.apple.developer.game-center: true`). */
32
+ export function setGameCenterEntitlement(entitlements) {
33
+ const e = entitlements && typeof entitlements === "object" ? entitlements : {};
34
+ e["com.apple.developer.game-center"] = true;
35
+ return e;
36
+ }
37
+
38
+ /**
39
+ * Android: add/replace a `<string name="...">value</string>` resource. Mutates +
40
+ * returns the parsed strings.xml object (`{ resources: { string: [...] } }`).
41
+ */
42
+ export function upsertStringResource(resourcesXml, name, value, opts) {
43
+ const xml = resourcesXml && typeof resourcesXml === "object" ? resourcesXml : {};
44
+ xml.resources = xml.resources || {};
45
+ const arr = Array.isArray(xml.resources.string) ? xml.resources.string : [];
46
+ const item = { _: String(value), $: { name } };
47
+ if (opts && opts.translatable === false) item.$.translatable = "false";
48
+ const idx = arr.findIndex((s) => s && s.$ && s.$.name === name);
49
+ if (idx >= 0) arr[idx] = item;
50
+ else arr.push(item);
51
+ xml.resources.string = arr;
52
+ return xml;
53
+ }
54
+
55
+ /**
56
+ * Android: add/replace a `<meta-data android:name=".." android:value="..">`
57
+ * under the application node (dedupe by name). Mutates + returns the app node.
58
+ */
59
+ export function addManifestMetaData(application, name, value) {
60
+ if (!application) return application;
61
+ application["meta-data"] = application["meta-data"] || [];
62
+ const arr = application["meta-data"];
63
+ const item = { $: { "android:name": name, "android:value": String(value) } };
64
+ const idx = arr.findIndex((m) => m && m.$ && m.$["android:name"] === name);
65
+ if (idx >= 0) arr[idx] = item;
66
+ else arr.push(item);
67
+ return application;
68
+ }