Browse Source

ios fb sdk

baxi 1 tuần trước cách đây
mục cha
commit
9e78155ce0

+ 3 - 1
.gitignore

@@ -23,4 +23,6 @@ node_modules/
 #//////////////////////////
 .idea/
 
-.gradle
+.gradle
+
+.creator

+ 2 - 3
assets/lobbyRes/ui/home/HomeView.prefab

@@ -16887,9 +16887,8 @@
     "tabGamesScroll": {
       "__id__": 269
     },
-    "gamePrefab": {
-      "__uuid__": "5afdeb54-c5fc-4e74-b2ee-8e4ce35f78d7",
-      "__expectedType__": "cc.Prefab"
+    "homeNotices": {
+      "__id__": 443
     },
     "_id": ""
   },

+ 6 - 1
assets/scripts/api/LobbyApi.ts

@@ -4,6 +4,7 @@ export const APP_ROUTES = get('routes');
 export const APP_PAGE_MODULE = API_BASE_PREFIX + 'pageModules/';
 export const APP_GAME_LIST = get('sc/game/list');
 export const APP_GAME_LAUNCH = get('sc/game/launch');
+export const APP_GAME_HISTORY = get('sc/game/history');
 
 export const APP_GAME_CARD_INFO = get('gameinfo');
 export const APP_GAME_ENTER_RECORD = get('game_enter/record');
@@ -13,7 +14,11 @@ export const APP_FAVO_REMOVE = get('favo/remove');
 export const APP_FAVO_GET = get('favo/get');
 
 export const APP_VIP_CONFIG = get("sc/vip/info");
-export const APP_VIP_CLAIM =  get("sc/vip/claim");
+export const APP_VIP_CLAIM = get("sc/vip/claim");
+
+export const APP_BIG_WIN = get('lobby/big-win-broadcast');
+
+
 
 export const APP_SERVICE = get('service');
 export const APP_USER_MESSAGE = get('user/message');

+ 46 - 10
assets/scripts/modules/lobby/ui/HomeView/HomeNotices.ts

@@ -1,12 +1,13 @@
 import { AppKernel } from '@scripts/app/AppKernel';
 import { CURRENCY_TYPE } from '@scripts/define/Define';
-import { INoticeData } from '@scripts/store/mainStore/data';
+import { IBigWinItem } from '@scripts/store/mainStore/data';
 import { formatMaskNickName, formatSCNum } from '@scripts/utils/Helper';
-import { _decorator, Component, Input, input, instantiate, KeyCode, Label, Node, NodePool, tween, Tween, UITransform, Vec3 } from 'cc';
+import { _decorator, Component, Input, input, instantiate, isValid, KeyCode, Label, Node, NodePool, tween, Tween, UITransform, Vec3 } from 'cc';
 const { ccclass, property } = _decorator;
 
 const MIN_MESSAGE_GAP = 50;
 const SCROLL_SPEED = 120;
+const NEXT_NOTICE_DELAY = 2;
 
 interface INoticeNode {
     label: Label,
@@ -24,6 +25,13 @@ export class HomeNotices extends Component {
     private readonly pool = new NodePool('HomeNotices');
     private readonly activeItems: Node[] = [];
     private readonly noticeNodes = new WeakMap<Node, INoticeNode>();
+    private timerID: number = 0;
+    private waitingForNext = false;
+
+    private readonly playNextNotice = () => {
+        this.waitingForNext = false;
+        this.startNotify();
+    };
 
     protected onLoad(): void {
         if (this.itemNode) this.itemNode.active = false;
@@ -42,23 +50,38 @@ export class HomeNotices extends Component {
     }
 
     protected onDestroy(): void {
+        this.unschedule(this.playNextNotice);
         for (let index = 0; index < this.activeItems.length; index += 1) {
             Tween.stopAllByTarget(this.activeItems[index]);
         }
         this.activeItems.length = 0;
+        this.stopTimer();
+    }
+
+    private stopTimer() {
+        if (this.timerID === 0) return;
+        clearTimeout(this.timerID);
+        this.timerID = 0;
     }
 
     startNotify(): void {
-        if (this.activeItems.length > 0) {
-            const last = this.activeItems[this.activeItems.length - 1];
-            if (last.x >= this.content.width) return;
-        }
+        // 外部刷新或旧消息播放结束不能跳过下一条消息的等待时间。
+        if (this.waitingForNext) return;
+        this.stopTimer();
 
         const mainStore = AppKernel.stores.mainStore;
         const data = mainStore.getNoticeData();
         if (!data) {
             // 队列已空且没有正在移动的消息时,隐藏整个公告区域。
-            if (this.activeItems.length === 0) this.node.active = false;
+            if (this.activeItems.length === 0) {
+                this.node.active = false;
+            }
+
+            this.timerID = setTimeout(async () => {
+                this.timerID = 0;
+                await AppKernel.stores.mainStore.reqBigWinNotices()
+                if (isValid(this.node)) this.startNotify();
+            }, 5000);
             return;
         }
 
@@ -66,10 +89,10 @@ export class HomeNotices extends Component {
         this.onNotify(data);
     }
 
-    onNotify(data: INoticeData): void {
+    onNotify(data: IBigWinItem): void {
         if (!this.itemNode || !this.content) return;
 
-        const msg = `${formatMaskNickName(data.nickName)} won ${CURRENCY_TYPE.SC} ${formatSCNum(data.value)}`;
+        const msg = `${formatMaskNickName(data.username)} won ${CURRENCY_TYPE.SC} ${formatSCNum(data.amount)}`;
 
         const item = this.pool.get() || instantiate(this.itemNode);
         const noticeNode = this.getNoticeNode(item);
@@ -93,9 +116,16 @@ export class HomeNotices extends Component {
         item.setPosition(startX, 0, item.position.z);
         this.activeItems.push(item);
 
+        // 消息右边缘进入 content 右边界,表示整条消息已经滑入。
+        // 超长消息也以尾部进入作为判定,不要求全文同时出现在可视区。
+        const fullyEnteredTime = Math.max(0, startX + itemWidth - this.content.width) / SCROLL_SPEED;
+        this.waitingForNext = true;
+        this.unschedule(this.playNextNotice);
+        this.scheduleOnce(this.playNextNotice, fullyEnteredTime + NEXT_NOTICE_DELAY);
+
         tween(item)
             .to((startX - endX) / SCROLL_SPEED, {
-                position: new Vec3(endX, 0, item.position.z),
+                x: endX,
             }, { easing: 'linear' })
             .call(() => {
                 this.recycle(item);
@@ -142,4 +172,10 @@ export class HomeNotices extends Component {
         item.removeFromParent();
         this.pool.put(item);
     }
+
+    async updateNotices() {
+        await AppKernel.stores.mainStore.reqBigWinNotices();
+        if (!isValid(this.node)) return;
+        this.startNotify();
+    }
 }

+ 4 - 2
assets/scripts/modules/lobby/ui/HomeView/HomeView.ts

@@ -11,6 +11,7 @@ import { IGameInfo } from '@scripts/store/gameStore/data';
 import { GameItem } from './GameItem';
 import { RED_POINTS } from '@scripts/app/AppRedpoint';
 import { formatGCNum, formatSCNum } from '@scripts/utils/Helper';
+import { HomeNotices } from './HomeNotices';
 
 const { ccclass, property } = _decorator;
 
@@ -41,8 +42,8 @@ export class HomeView extends UIView {
     @property(VirtualList)
     tabGamesScroll: VirtualList;
 
-    @property(Prefab)
-    gamePrefab: Prefab;
+    @property(HomeNotices)
+    homeNotices: HomeNotices;
 
     private userNode: UserNode;
     private gcModel: CoinModel;
@@ -132,6 +133,7 @@ export class HomeView extends UIView {
 
     protected onOpen(_params: unknown) {
         this.updateView();
+        this.homeNotices?.updateNotices();
     }
 
     private onUpdateFirstPack() {

+ 5 - 1
assets/scripts/modules/lobby/ui/PlayHistory/PlayHistory.ts

@@ -22,6 +22,10 @@ export class PlayHistory extends UIPopup {
     @property(Node)
     btnClose: Node;
 
+    protected async onBeforeOpen(_params: unknown) {
+        await AppKernel.stores.gameStore.reqHistory(1);
+    }
+
     protected onCreate(): void | Promise<void> {
         addBtnClick(this.btnClose, this.closeSelf, this);
     }
@@ -31,7 +35,7 @@ export class PlayHistory extends UIPopup {
     }
 
     private updateView() {
-        // LoopList.ins("PlayHistory").init(this.scrollView, null, 22);
+        
     }
 }
 

+ 0 - 9
assets/scripts/platform/FaceBook/FBAndroidImpl.ts.meta

@@ -1,9 +0,0 @@
-{
-  "ver": "4.0.24",
-  "importer": "typescript",
-  "imported": true,
-  "uuid": "9cbe5430-29fb-468a-96ae-7c57e998b75d",
-  "files": [],
-  "subMetas": {},
-  "userData": {}
-}

+ 0 - 49
assets/scripts/platform/FaceBook/FBIOSImpl.ts

@@ -1,49 +0,0 @@
-import {
-    FacebookImpl,
-    IFBEventParams,
-    IFBShareOptions,
-    IFBShareRes,
-} from "./FacebookImpl";
-
-export class FBIOSImpl extends FacebookImpl {
-    init() {
-
-
-    }
-
-    protected async onLogin() {
-        return { accessToken: "", isCancel: false };
-    }
-
-    async logOut() {
-        return true;
-    }
-
-    async share(_options: IFBShareOptions): Promise<IFBShareRes> {
-        return {
-            success: false,
-            isCancel: false,
-            reason: 'Facebook iOS 原生实现尚未接入。',
-        };
-    }
-
-    async logEvent(
-        _eventName: string,
-        _parameters: IFBEventParams = {},
-        _valueToSum?: number,
-    ): Promise<boolean> {
-        return false;
-    }
-
-    async logPurchase(
-        _amount: number,
-        _currency: string,
-        _parameters: IFBEventParams = {},
-    ): Promise<boolean> {
-        return false;
-    }
-
-    async flushEvents(): Promise<boolean> {
-        return false;
-    }
-}

+ 0 - 9
assets/scripts/platform/FaceBook/FBIOSImpl.ts.meta

@@ -1,9 +0,0 @@
-{
-  "ver": "4.0.24",
-  "importer": "typescript",
-  "imported": true,
-  "uuid": "e924a4b7-be34-4811-b30f-6d47ea6e8e11",
-  "files": [],
-  "subMetas": {},
-  "userData": {}
-}

+ 19 - 11
assets/scripts/platform/FaceBook/FBAndroidImpl.ts → assets/scripts/platform/FaceBook/FBNativeImpl.ts

@@ -29,6 +29,8 @@ interface NativeResponse<T = Record<string, never>> {
 interface LoginData {
     accessToken?: string;
     userId?: string;
+    authenticationToken?: string;
+    nonce?: string;
 }
 
 interface ShareData {
@@ -46,7 +48,7 @@ interface PendingRequest {
     timer: ReturnType<typeof setTimeout>;
 }
 
-export class FBAndroidImpl extends FacebookImpl {
+export class FBNativeImpl extends FacebookImpl {
     private _requestSeed = 0;
     private _pendingRequests = new Map<string, PendingRequest>();
     private _initPromise: Promise<boolean> | null = null;
@@ -62,14 +64,14 @@ export class FBAndroidImpl extends FacebookImpl {
                 return true;
             })
             .catch((error) => {
-                console.error("[Facebook][Android] 初始化失败:", error);
+                console.error("[Facebook][Native] 初始化失败:", error);
                 return false;
             });
     }
 
     protected async onLogin(): Promise<IFBLoginRes> {
         if (!(await this.ensureInitialized())) {
-            return this.loginFailure("Facebook Android SDK 尚未初始化。");
+            return this.loginFailure("Facebook 原生 SDK 尚未初始化。");
         }
 
         try {
@@ -87,12 +89,18 @@ export class FBAndroidImpl extends FacebookImpl {
             }
 
             const accessToken = response.data?.accessToken || "";
-            if (!accessToken) {
-                return this.loginFailure("Facebook 登录未返回 Access Token。");
+            const authenticationToken = response.data?.authenticationToken || "";
+            if (!accessToken && !authenticationToken) {
+                return this.loginFailure("Facebook 登录未返回有效令牌。");
             }
 
             return {
                 accessToken,
+                ...(authenticationToken ? {
+                    authenticationToken,
+                    nonce: response.data?.nonce,
+                    tokenType: "authentication" as const,
+                } : {}),
                 isCancel: false,
                 status: "connected",
             };
@@ -107,7 +115,7 @@ export class FBAndroidImpl extends FacebookImpl {
             await this.request("logout");
             return true;
         } catch (error) {
-            console.error("[Facebook][Android] 登出失败:", error);
+            console.error("[Facebook][Native] 登出失败:", error);
             return false;
         }
     }
@@ -117,7 +125,7 @@ export class FBAndroidImpl extends FacebookImpl {
             return {
                 success: false,
                 isCancel: false,
-                reason: "Facebook Android SDK 尚未初始化。",
+                reason: "Facebook 原生 SDK 尚未初始化。",
             };
         }
 
@@ -157,7 +165,7 @@ export class FBAndroidImpl extends FacebookImpl {
             const response = await this.request<EventData>("logEvent", params);
             return response.data?.accepted === true;
         } catch (error) {
-            console.error(`[Facebook][Android] 事件 ${eventName} 记录失败:`, error);
+            console.error(`[Facebook][Native] 事件 ${eventName} 记录失败:`, error);
             return false;
         }
     }
@@ -176,7 +184,7 @@ export class FBAndroidImpl extends FacebookImpl {
             });
             return response.data?.accepted === true;
         } catch (error) {
-            console.error("[Facebook][Android] 购买事件记录失败:", error);
+            console.error("[Facebook][Native] 购买事件记录失败:", error);
             return false;
         }
     }
@@ -187,7 +195,7 @@ export class FBAndroidImpl extends FacebookImpl {
             const response = await this.request<EventData>("flushEvents");
             return response.data?.accepted === true;
         } catch (error) {
-            console.error("[Facebook][Android] App Events 刷新失败:", error);
+            console.error("[Facebook][Native] App Events 刷新失败:", error);
             return false;
         }
     }
@@ -234,7 +242,7 @@ export class FBAndroidImpl extends FacebookImpl {
         try {
             response = JSON.parse(payload) as NativeResponse<any>;
         } catch (error) {
-            console.error("[Facebook][Android] 无法解析原生响应:", error);
+            console.error("[Facebook][Native] 无法解析原生响应:", error);
             return;
         }
 

+ 1 - 0
assets/scripts/platform/FaceBook/FBNativeImpl.ts.meta

@@ -0,0 +1 @@
+{"ver":"4.0.24","importer":"typescript","imported":true,"uuid":"811c578f-7d73-43cf-9e08-1af3fcfc16cb","files":[],"subMetas":{},"userData":{}}

+ 3 - 8
assets/scripts/platform/FaceBook/Facebook.ts

@@ -1,17 +1,12 @@
 import { ANDROID, IOS } from "cc/env";
 import { FacebookImpl } from "./FacebookImpl";
-import { FBAndroidImpl } from "./FBAndroidImpl";
 import { FBH5Impl } from "./FBH5Impl";
-import { FBIOSImpl } from "./FBIOSImpl";
+import { FBNativeImpl } from "./FBNativeImpl";
 
 
 function getFaceBookImpl(): FacebookImpl {
-    if (ANDROID) {
-        return new FBAndroidImpl();
-    }
-
-    if (IOS) {
-        return new FBIOSImpl();
+    if (ANDROID || IOS) {
+        return new FBNativeImpl();
     }
 
     return new FBH5Impl();

+ 4 - 0
assets/scripts/platform/FaceBook/FacebookImpl.ts

@@ -1,5 +1,9 @@
 export interface IFBLoginRes {
     accessToken: string;
+    /** iOS Limited Login JWT; never use as a Graph API access token. */
+    authenticationToken?: string;
+    tokenType?: "access" | "authentication";
+    nonce?: string;
     isCancel: boolean;
     status?: string;
     reason?: string;

+ 8 - 3
assets/scripts/store/gameStore/GameStore.ts

@@ -1,8 +1,8 @@
-import { APP_FAVO_ADD, APP_FAVO_REMOVE, APP_GAME_LAUNCH, APP_GAME_LIST } from "@scripts/api";
+import { APP_FAVO_ADD, APP_FAVO_REMOVE, APP_GAME_HISTORY, APP_GAME_LAUNCH, APP_GAME_LIST } from "@scripts/api";
 import { Http } from "@scripts/request";
 import { checkResponse, success } from '@scripts/api/ApiHelper';
-import { IGameInfo, IGameLaunch, IGameListParams } from "./data";
-import { CURRENCY_TYPE, CurrencyType, GAME_TABS, GameTabType } from "@scripts/define/Define";
+import { IGameHistoryRes, IGameInfo, IGameLaunch, IGameListParams } from "./data";
+import { CurrencyType, GAME_TABS, GameTabType } from "@scripts/define/Define";
 
 const UPDATE_LIST_CD = 10 * 60 * 1000;
 
@@ -138,6 +138,11 @@ class GameStore {
             this._recentMap[currency] = recents;
         }
     }
+
+    async reqHistory(page: number, pageSize = 20) {
+        let res = await Http.call<IGameHistoryRes>(APP_GAME_HISTORY, { page, pageSize })
+
+    }
 }
 
 export const gameStore = new GameStore();

+ 24 - 0
assets/scripts/store/gameStore/data.ts

@@ -39,4 +39,28 @@ export interface IGameLaunch {
     providerGameId: string;
     currency: string;
     launchUrl: string;
+}
+
+export interface IGameHistory {
+    roundId: number;
+    gameName: string;
+    gameTime: string;
+    entry: number;
+    win: number;
+    currency: string;
+}
+
+export interface IGameHistoryRes {
+    current_page: number;
+    data: IGameHistory[];
+    first_page_url?: string;
+    from: number;
+    last_page: number;
+    last_page_url?: string;
+    next_page_url?: string;
+    path?: string;
+    per_page: number;
+    prev_page_url?: string;
+    to: number;
+    total: number;
 }

+ 23 - 3
assets/scripts/store/mainStore/MainStore.ts

@@ -15,6 +15,7 @@ import {
     APP_BIND_3RD,
     APP_UNBIND_3RD,
     APP_UNBIND_3RD_CONFIRM,
+    APP_BIG_WIN,
 } from "../../api";
 import { success } from '@scripts/api/ApiHelper';
 import { AppKernel } from "../../app/AppKernel";
@@ -23,8 +24,9 @@ import { LOCAL_CURRENCY_MODE, LOCAL_SIGN } from "../../constants/Localkey";
 import { ApiEnvelope, Http } from "../../request";
 import { getEnterPlatform, localStorageGet, localStorageSet } from "../../utils/Helper";
 import {
+    IBigWinItem,
+    IBigWinRes,
     IForgetPassReq,
-    INoticeData,
     IProtectInfo,
     IRoutesResponse,
     IUserData,
@@ -53,8 +55,9 @@ class MainStore {
     private _withdrawFeeRate: number;
     private _serverKick: boolean;
     private _protectInfo: IProtectInfo = null;
-    private _noticesList: INoticeData[] = [];
+    private _noticesList: IBigWinItem[] = [];
     private _currencyKind: CurrencyType;
+    private _lastNoticeIdx = 0;
 
     private _otpTsMap: Record<OtpType, number> = {
         [OtpType.Main]: 0,
@@ -392,7 +395,9 @@ class MainStore {
     }
 
     getNoticeData() {
-        return this._noticesList.shift();
+        const data = this._noticesList.shift();
+        if (!!data) this._lastNoticeIdx = data.id;
+        return data;
     }
 
     async reqBindPhoneConfirm({ account, code, password }) {
@@ -497,7 +502,22 @@ class MainStore {
         return false;
     }
 
+    async reqBigWinNotices() {
+        const params = {
+            currency: CURRENCY_TYPE.SC,
+            lastId: this._lastNoticeIdx,
+        };
+        const res = await Http.call<IBigWinRes>(APP_BIG_WIN, params);
+        if (success(res)) {
+            let list = res.data.items || [];
+            list = list.sort((a, b) => a.id - b.id)
+            this._noticesList = [...this._noticesList, ...list];
+            console.log(this._noticesList)
+            return this._noticesList.length > 0;
+        }
 
+        return false;
+    }
 
 }
 

+ 17 - 3
assets/scripts/store/mainStore/data.ts

@@ -70,7 +70,21 @@ export interface IForgetPassReq {
     country?: string | number;
 }
 
-export interface INoticeData {
-    nickName: string,
-    value: number,
+export interface IBigWinItem {
+    id: number;
+    username: string;
+    faceId: number;
+    gameId: number;
+    gameName: string;
+    amount: number;
+    currency: string;
+    level: string;
+    createdAt: number;
+}
+
+export interface IBigWinRes {
+    serverTime: number;
+    latestId: number;
+    reset: boolean;
+    items: IBigWinItem[];
 }

+ 6 - 0
native/engine/ios/AppDelegate.mm

@@ -32,6 +32,7 @@
 #include "platform/ios/IOSPlatform.h"
 #import "platform/ios/AppDelegateBridge.h"
 #import "service/SDKWrapper.h"
+#import "service/FBWrapper.h"
 
 @implementation AppDelegate
 @synthesize window;
@@ -41,6 +42,7 @@
 #pragma mark Application lifecycle
 
 - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
+    [[FBWrapper shared] application:application didFinishLaunchingWithOptions:launchOptions];
     [[SDKWrapper shared] application:application didFinishLaunchingWithOptions:launchOptions];
     appDelegateBridge = [[AppDelegateBridge alloc] init];
     
@@ -69,6 +71,10 @@
     [appDelegateBridge applicationWillResignActive:application];
 }
 
+- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options {
+    return [[FBWrapper shared] application:application openURL:url options:options];
+}
+
 - (void)applicationDidBecomeActive:(UIApplication *)application {
     /*
      Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.

+ 6 - 0
native/engine/ios/CMakeLists.txt

@@ -18,3 +18,9 @@ set(EXECUTABLE_NAME ${APP_NAME}-mobile)
 cc_ios_before_target(${EXECUTABLE_NAME})
 add_executable(${EXECUTABLE_NAME} ${CC_ALL_SOURCES})
 cc_ios_after_target(${EXECUTABLE_NAME})
+
+target_sources(${EXECUTABLE_NAME} PRIVATE ${CC_PROJECT_DIR}/service/FBWrapper.mm)
+set_source_files_properties(${CC_PROJECT_DIR}/service/FBWrapper.mm PROPERTIES COMPILE_FLAGS "-fobjc-arc")
+set_target_properties(${EXECUTABLE_NAME} PROPERTIES
+    XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "12.0"
+    XCODE_ATTRIBUTE_ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES "YES")

+ 42 - 0
native/engine/ios/FACEBOOK.md

@@ -0,0 +1,42 @@
+# Facebook iOS 接入(Creator 3.8.x)
+
+实现初始化、Limited Login、登出、链接分享、自定义事件、购买事件和事件刷新。
+TypeScript 的 FBIOSImpl 与 Android 共用 FBNativeImpl 请求桥;原生由 service/FBWrapper.mm 实现。
+AppDelegate 直接持有 FBWrapper 单例,无需在 service.json 再次注册。
+
+## 在 Mac 上构建
+
+1. 在 Creator iOS 构建面板的「SDK 管理 → Facebook」勾选启用,填写 App ID、Client Token 和自动事件选项,构建并生成 Xcode 工程。
+   插件更新源 Info.plist 的凭据、fb<AppID> URL Scheme 及查询白名单。Xcode 工程生成必须在这些配置写入之后。
+2. 安装 CocoaPods 后,在本目录执行(替换为生成工程的实际绝对路径):
+
+   ```sh
+   COCOS_IOS_PROJECT="/absolute/path/game_2c.xcodeproj" pod install
+   ```
+
+   若 target 改名,额外设置 `COCOS_IOS_TARGET="实际 target 名"`。
+   SDK 固定为 18.1.1(CoreKit/LoginKit/ShareKit),最低 iOS 12。
+3. 打开生成工程旁的 `FacebookGame.xcworkspace`,设置签名,在 iPhone 上运行。
+   Creator 重新生成 Xcode 工程后,重新执行 pod install;不要只打开 xcodeproj。
+4. 在 Meta 后台增加 iOS 平台,Bundle ID 必须与实际包一致;开发模式账号须有应用角色/测试权限。
+
+当前 Windows 环境未进行 Xcode 编译、CocoaPods 安装或真机验证。
+
+## 登录结果与后端
+
+默认使用 Limited Login,不主动请求 ATT,不开启广告追踪。
+成功时 `status === 'connected'`,返回 `authenticationToken`(JWT)、`nonce`、`tokenType: 'authentication'`;`accessToken` 为空。
+服务端需要验证 Meta JWT 的签名、issuer、audience(App ID)、有效期和 nonce,并做防重放处理。
+不能把 JWT 直接交给现有只接受 Graph API Access Token 的接口;业务登录接口需支持此类令牌后才能完成服务器登录。
+当前没有修改业务登录接口或 TrackEvents.ts。
+
+## 验证清单
+
+- 初始化失败能返回配置错误;正常初始化后 isInitialized 为 true。
+- 真机登录成功/取消/失败;浏览器或 Facebook App 返回游戏;登出后可再次登录。
+- 链接分享成功/取消/失败;重复操作被拒绝。
+- Meta 测试事件里检查 logEvent、logPurchase 和 flushEvents;返回 true 仅代表本地 SDK 接受调用。
+- 自动应用事件由 FacebookAutoLogAppEventsEnabled 控制;手动事件仍由业务显式调用。
+- 重复构建及修改 App ID 后检查 Info.plist URL Scheme,其他 SDK Scheme 应保留。
+
+依赖来源:https://github.com/facebook/facebook-ios-sdk/tree/v18.1.1

+ 15 - 0
native/engine/ios/Podfile

@@ -0,0 +1,15 @@
+platform :ios, '12.0'
+use_frameworks!
+
+# Creator generates the Xcode project outside this source directory.
+# COCOS_IOS_PROJECT=/absolute/path/game_2c.xcodeproj pod install
+project_path = ENV['COCOS_IOS_PROJECT']
+abort 'Set COCOS_IOS_PROJECT to the generated .xcodeproj path.' unless project_path && File.directory?(project_path)
+project project_path
+workspace File.join(File.dirname(project_path), 'FacebookGame')
+
+target ENV.fetch('COCOS_IOS_TARGET', 'game_2c-mobile') do
+  pod 'FBSDKCoreKit', '18.1.1'
+  pod 'FBSDKLoginKit', '18.1.1'
+  pod 'FBSDKShareKit', '18.1.1'
+end

+ 7 - 0
native/engine/ios/service/FBWrapper.h

@@ -0,0 +1,7 @@
+#import <UIKit/UIKit.h>
+
+@interface FBWrapper : NSObject
++ (instancetype)shared;
+- (void)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options;
+- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options;
+@end

+ 209 - 0
native/engine/ios/service/FBWrapper.mm

@@ -0,0 +1,209 @@
+#import "FBWrapper.h"
+#import <FBSDKCoreKit/FBSDKCoreKit.h>
+#import <FBSDKLoginKit/FBSDKLoginKit.h>
+#import <FBSDKShareKit/FBSDKShareKit.h>
+#import "platform/apple/JsbBridgeWrapper.h"
+#include "application/ApplicationManager.h"
+#include "application/BaseApplication.h"
+#include "base/Scheduler.h"
+#include "engine/BaseEngine.h"
+#include <cmath>
+
+@interface FBWrapper () <FBSDKSharingDelegate>
+@property(nonatomic, strong) FBSDKLoginManager *loginManager;
+@property(nonatomic, strong) FBSDKShareDialog *shareDialog;
+@property(nonatomic, strong) NSDictionary *shareRequest;
+@property(nonatomic, copy) NSString *startupError;
+@property(nonatomic) BOOL loginPending;
+@end
+
+@implementation FBWrapper
++ (instancetype)shared {
+    static FBWrapper *instance;
+    static dispatch_once_t once;
+    dispatch_once(&once, ^{ instance = [FBWrapper new]; });
+    return instance;
+}
+
+- (void)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)options {
+    // Register even on configuration failure so JS receives a useful error instead of timing out.
+    [[JsbBridgeWrapper sharedInstance] addScriptEventListener:@"facebook.request" listener:^(NSString *payload) {
+        dispatch_async(dispatch_get_main_queue(), ^{ [self handlePayload:payload]; });
+    }];
+    NSDictionary *info = NSBundle.mainBundle.infoDictionary;
+    if (![info[@"FacebookAppID"] isKindOfClass:NSString.class] || ![info[@"FacebookAppID"] length] ||
+        ![info[@"FacebookClientToken"] isKindOfClass:NSString.class] || ![info[@"FacebookClientToken"] length]) {
+        self.startupError = @"请在 SDK 管理构建面板启用 Facebook 并填写 App ID 和 Client Token。";
+        return;
+    }
+    @try {
+        [[FBSDKApplicationDelegate sharedInstance] application:application didFinishLaunchingWithOptions:options];
+        self.loginManager = [FBSDKLoginManager new];
+    } @catch (NSException *exception) {
+        self.startupError = exception.reason ?: @"Facebook 初始化失败。";
+    }
+}
+
+- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options {
+    if (!self.loginManager) return NO;
+    return [[FBSDKApplicationDelegate sharedInstance] application:application openURL:url options:options];
+}
+
+- (void)reply:(NSDictionary *)request data:(NSDictionary *)data cancelled:(BOOL)cancelled error:(NSString *)error {
+    NSMutableDictionary *response = [@{@"requestId": request[@"requestId"] ?: @"",
+        @"method": request[@"method"] ?: @"", @"success": @(!error), @"cancelled": @(cancelled)} mutableCopy];
+    if (data) response[@"data"] = data;
+    if (error) response[@"error"] = @{@"code": @"FACEBOOK_IOS_ERROR", @"message": error};
+    NSData *json = [NSJSONSerialization dataWithJSONObject:response options:0 error:nil];
+    NSString *payload = [[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding];
+    auto app = CC_CURRENT_APPLICATION();
+    if (!app || !app->getEngine() || !payload) return;
+    app->getEngine()->getScheduler()->performFunctionInCocosThread([payload]() {
+        [[JsbBridgeWrapper sharedInstance] dispatchEventToScript:@"facebook.response" arg:payload];
+    });
+}
+
+- (UIViewController *)presenter {
+    UIViewController *controller = UIApplication.sharedApplication.delegate.window.rootViewController;
+    while (controller.presentedViewController) controller = controller.presentedViewController;
+    return controller;
+}
+
+- (void)handlePayload:(NSString *)payload {
+    id decoded = [NSJSONSerialization JSONObjectWithData:[payload dataUsingEncoding:NSUTF8StringEncoding] options:0 error:nil];
+    if (![decoded isKindOfClass:NSDictionary.class]) return;
+    NSDictionary *request = decoded;
+    if (![request[@"requestId"] isKindOfClass:NSString.class] || ![request[@"method"] isKindOfClass:NSString.class]) return;
+    NSString *method = request[@"method"];
+    NSDictionary *params = [request[@"params"] isKindOfClass:NSDictionary.class] ? request[@"params"] : @{};
+    @try {
+        if (!self.loginManager) {
+            [self reply:request data:nil cancelled:NO error:self.startupError ?: @"Facebook SDK 未初始化。"];
+        } else if ([method isEqualToString:@"init"]) {
+            [self reply:request data:@{} cancelled:NO error:nil];
+        } else if ([method isEqualToString:@"login"]) {
+            [self login:request];
+        } else if ([method isEqualToString:@"logout"]) {
+            if (self.loginPending) {
+                [self reply:request data:nil cancelled:NO error:@"Facebook 登录仍在进行中。"];
+                return;
+            }
+            [self.loginManager logOut];
+            [self reply:request data:@{} cancelled:NO error:nil];
+        } else if ([method isEqualToString:@"share"]) {
+            [self share:request params:params];
+        } else if ([method isEqualToString:@"logEvent"] || [method isEqualToString:@"logPurchase"] || [method isEqualToString:@"flushEvents"]) {
+            [self event:request params:params];
+        } else {
+            [self reply:request data:nil cancelled:NO error:@"不支持的 Facebook 方法。"];
+        }
+    } @catch (NSException *exception) {
+        if ([method isEqualToString:@"login"]) self.loginPending = NO;
+        if ([method isEqualToString:@"share"]) { self.shareRequest = nil; self.shareDialog = nil; }
+        [self reply:request data:nil cancelled:NO error:exception.reason ?: @"Facebook SDK 调用失败。"];
+    }
+}
+
+- (void)login:(NSDictionary *)request {
+    if (self.loginPending || self.shareRequest || !self.presenter) {
+        [self reply:request data:nil cancelled:NO error:@"已有 Facebook 操作进行中,或没有可用窗口。"];
+        return;
+    }
+    // Limited Login works without an ATT prompt. Each attempt gets its own nonce.
+    NSString *nonce = NSUUID.UUID.UUIDString;
+    FBSDKLoginConfiguration *config = [[FBSDKLoginConfiguration alloc]
+        initWithPermissions:@[@"public_profile", @"email"] tracking:FBSDKLoginTrackingLimited nonce:nonce];
+    self.loginPending = YES;
+    [self.loginManager logInFromViewController:self.presenter configuration:config
+        completion:^(FBSDKLoginManagerLoginResult *result, NSError *error) {
+        self.loginPending = NO;
+        if (error) { [self reply:request data:nil cancelled:NO error:error.localizedDescription]; return; }
+        if (result.isCancelled) { [self reply:request data:nil cancelled:YES error:nil]; return; }
+        NSString *token = result.authenticationToken.tokenString;
+        if (!token.length) { [self reply:request data:nil cancelled:NO error:@"Facebook 未返回身份令牌。"] ; return; }
+        [self reply:request data:@{@"authenticationToken": token, @"nonce": nonce} cancelled:NO error:nil];
+    }];
+}
+
+- (void)share:(NSDictionary *)request params:(NSDictionary *)params {
+    if (self.shareRequest || self.loginPending || !self.presenter) {
+        [self reply:request data:nil cancelled:NO error:@"已有 Facebook 操作进行中,或没有可用窗口。"];
+        return;
+    }
+    NSString *value = [params[@"url"] isKindOfClass:NSString.class] ? params[@"url"] : @"";
+    NSURL *url = [NSURL URLWithString:value];
+    if (!url.host.length || ![@[@"https", @"http"] containsObject:url.scheme.lowercaseString]) {
+        [self reply:request data:nil cancelled:NO error:@"分享链接必须为有效的 HTTP(S) URL。"];
+        return;
+    }
+    FBSDKShareLinkContent *content = [FBSDKShareLinkContent new];
+    content.contentURL = url;
+    if ([params[@"quote"] isKindOfClass:NSString.class]) content.quote = params[@"quote"];
+    if ([params[@"hashtag"] isKindOfClass:NSString.class] && [params[@"hashtag"] length]) {
+        content.hashtag = [[FBSDKHashtag alloc] initWithString:params[@"hashtag"]];
+    }
+    self.shareRequest = request;
+    self.shareDialog = [[FBSDKShareDialog alloc] initWithViewController:self.presenter content:content delegate:self];
+    self.shareDialog.mode = FBSDKShareDialogModeAutomatic;
+    if (![self.shareDialog canShow] || ![self.shareDialog show]) {
+        if (self.shareRequest) [self reply:request data:nil cancelled:NO error:@"无法打开 Facebook 分享窗口。"];
+        self.shareRequest = nil;
+        self.shareDialog = nil;
+    }
+}
+
+- (void)sharer:(id<FBSDKSharing>)sharer didCompleteWithResults:(NSDictionary *)results {
+    if (self.shareRequest) [self reply:self.shareRequest data:@{@"postId": results[@"postId"] ?: @""} cancelled:NO error:nil];
+    self.shareRequest = nil; self.shareDialog = nil;
+}
+- (void)sharer:(id<FBSDKSharing>)sharer didFailWithError:(NSError *)error {
+    if (self.shareRequest) [self reply:self.shareRequest data:nil cancelled:NO error:error.localizedDescription];
+    self.shareRequest = nil; self.shareDialog = nil;
+}
+- (void)sharerDidCancel:(id<FBSDKSharing>)sharer {
+    if (self.shareRequest) [self reply:self.shareRequest data:nil cancelled:YES error:nil];
+    self.shareRequest = nil; self.shareDialog = nil;
+}
+
+- (void)event:(NSDictionary *)request params:(NSDictionary *)params {
+    NSString *method = request[@"method"];
+    NSDictionary *values = params[@"parameters"] ?: @{};
+    if (![values isKindOfClass:NSDictionary.class]) {
+        [self reply:request data:nil cancelled:NO error:@"事件参数必须为字典。"];
+        return;
+    }
+    for (id key in values) {
+        id value = values[key];
+        if (![key isKindOfClass:NSString.class] ||
+            (![value isKindOfClass:NSString.class] && ![value isKindOfClass:NSNumber.class])) {
+            [self reply:request data:nil cancelled:NO error:@"事件参数只支持字符串或数字。"];
+            return;
+        }
+    }
+    if ([method isEqualToString:@"flushEvents"]) {
+        [FBSDKAppEvents.shared flush];
+    } else if ([method isEqualToString:@"logPurchase"]) {
+        NSNumber *amount = params[@"amount"];
+        NSString *currency = params[@"currency"];
+        if (![amount isKindOfClass:NSNumber.class] || !std::isfinite(amount.doubleValue) || amount.doubleValue < 0 ||
+            ![currency isKindOfClass:NSString.class] ||
+            [currency rangeOfString:@"^[A-Za-z]{3}$" options:NSRegularExpressionSearch].location == NSNotFound) {
+            [self reply:request data:nil cancelled:NO error:@"购买金额或 ISO 4217 货币代码无效。"];
+            return;
+        }
+        [FBSDKAppEvents.shared logPurchase:amount.doubleValue currency:currency.uppercaseString parameters:values];
+    } else {
+        NSString *name = params[@"eventName"];
+        NSNumber *sum = params[@"valueToSum"];
+        if (![name isKindOfClass:NSString.class] || !name.length ||
+            (sum && (![sum isKindOfClass:NSNumber.class] || !std::isfinite(sum.doubleValue)))) {
+            [self reply:request data:nil cancelled:NO error:@"事件名称或累计数值无效。"];
+            return;
+        }
+        if (sum) [FBSDKAppEvents.shared logEvent:name valueToSum:sum.doubleValue parameters:values];
+        else [FBSDKAppEvents.shared logEvent:name parameters:values];
+    }
+    // Accepted by the local SDK, not a delivery receipt from Meta.
+    [self reply:request data:@{@"accepted": @YES} cancelled:NO error:nil];
+}
+@end