├── doctools ├── .gitignore ├── GodotApplePlugins.webp ├── Makefile └── version.py ├── test-apple-godot-api ├── addons ├── auth_result.gd ├── control.gd.uid ├── auth_result.gd.uid ├── .editorconfig ├── .gitattributes ├── .gitignore ├── project.godot ├── icon.svg ├── icon.svg.import ├── control.tscn └── control.gd ├── addons └── GodotApplePlugins │ ├── godot_apple_plugins.gdextension.uid │ └── godot_apple_plugins.gdextension ├── GodotApplePlugins.png ├── .gitignore ├── Sources └── GodotApplePlugins │ ├── GameCenter │ ├── GKGameActivity.swift │ ├── GKLeaderboardSet.swift │ ├── Entitlements.md │ ├── GKGameActivityDefinition.swift │ ├── GKSavedGame.swift │ ├── GKMatchRequest.swift │ ├── GameCenter.swift │ ├── GKPlayer.swift │ ├── GKGameCenterViewController.swift │ ├── GKLocalPlayer.swift │ ├── GKMatch.swift │ ├── GKAchievement.swift │ └── GKMatchMakerViewController.swift │ ├── Foundation │ └── Foundation.swift │ ├── AuthenticationServices │ ├── ASPasswordCredential.swift │ ├── ASAuthorizationAppleIDCredential.swift │ └── ASAuthorizationController.swift │ ├── Shared │ ├── OSIntegration.swift │ ├── AppKitIntegration.swift │ ├── UIKitIntegration.swift │ └── SwiftUIIntegration.swift │ ├── StoreKit │ ├── StoreTransaction.swift │ ├── StoreView.swift │ ├── ProductView.swift │ ├── SubscriptionPeriod.swift │ ├── SubscriptionOffer.swift │ ├── SubscriptionOfferView.swift │ ├── StoreProduct.swift │ └── SubscriptionStoreView.swift │ ├── GodotApplePlugins.swift │ ├── AVFoundation │ └── GodotAVAudioSession.swift │ └── UI │ └── AppleFilePicker.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── doc_classes ├── SignalProxy.xml ├── Foundation.xml ├── GKLeaderboardSet.xml ├── ASPasswordCredential.xml ├── StoreProductSubscriptionPeriod.xml ├── StoreProductSubscriptionOffer.xml ├── StoreView.xml ├── GKLeaderboardEntry.xml ├── GKSavedGame.xml ├── StoreProductPaymentMode.xml ├── SubscriptionOfferView.xml ├── StoreProduct.xml ├── StoreTransaction.xml ├── StoreProductPurchaseOption.xml ├── ProductView.xml ├── AppleFilePicker.xml ├── AVAudioSession.xml ├── SubscriptionStoreView.xml ├── ASAuthorizationAppleIDCredential.xml ├── GameCenterManager.xml ├── GKMatchRequest.xml ├── GKPlayer.xml ├── AppleURL.xml ├── GKGameCenterViewController.xml ├── ASAuthorizationController.xml ├── GKMatchmakerViewController.xml ├── GKAchievementDescription.xml ├── GKAchievement.xml ├── StoreKitManager.xml ├── GKLocalPlayer.xml ├── GKMatch.xml └── GKLeaderboard.xml ├── Package.resolved ├── LICENSE ├── Package.swift ├── .github └── workflows │ └── build-and-release.yml ├── README.md ├── fix_doc_enums.sh └── Makefile /doctools/.gitignore: -------------------------------------------------------------------------------- 1 | docs 2 | -------------------------------------------------------------------------------- /test-apple-godot-api/addons: -------------------------------------------------------------------------------- 1 | ../addons -------------------------------------------------------------------------------- /test-apple-godot-api/auth_result.gd: -------------------------------------------------------------------------------- 1 | extends Label 2 | -------------------------------------------------------------------------------- /test-apple-godot-api/control.gd.uid: -------------------------------------------------------------------------------- 1 | uid://k565oj0kotti 2 | -------------------------------------------------------------------------------- /test-apple-godot-api/auth_result.gd.uid: -------------------------------------------------------------------------------- 1 | uid://rppl5q7yp2jd 2 | -------------------------------------------------------------------------------- /test-apple-godot-api/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | -------------------------------------------------------------------------------- /addons/GodotApplePlugins/godot_apple_plugins.gdextension.uid: -------------------------------------------------------------------------------- 1 | uid://b413wupyyhvvu 2 | -------------------------------------------------------------------------------- /GodotApplePlugins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/migueldeicaza/GodotApplePlugins/HEAD/GodotApplePlugins.png -------------------------------------------------------------------------------- /test-apple-godot-api/.gitattributes: -------------------------------------------------------------------------------- 1 | # Normalize EOL for all files that Git considers text files. 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store 3 | .xcodebuild* 4 | .swiftpm 5 | .build 6 | .xcframework 7 | .xcodebuild 8 | .xcodebuildx86_64 9 | -------------------------------------------------------------------------------- /doctools/GodotApplePlugins.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/migueldeicaza/GodotApplePlugins/HEAD/doctools/GodotApplePlugins.webp -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKGameActivity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GKGameActivity.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/20/25. 6 | // 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test-apple-godot-api/.gitignore: -------------------------------------------------------------------------------- 1 | # Godot 4+ specific ignores 2 | .godot/ 3 | /android/ 4 | *log 5 | *ipa 6 | *pck 7 | *xcarchive 8 | *xcodeproj 9 | TestAppleGodotApi 10 | ExportOptions.plist 11 | DistributionSummary.plist 12 | -------------------------------------------------------------------------------- /doctools/Makefile: -------------------------------------------------------------------------------- 1 | DOC_CLASSES := $(abspath ../doc_classes) 2 | 3 | run: 4 | python3 make_rst.py -o docs -l "en" --filter "$(DOC_CLASSES)" --allow-warnings "$(HOME)/cvs/master-godot/editor/doc/classes" $(DOC_CLASSES) 5 | 6 | html: run 7 | sphinx-build -b html docs ../docs 8 | -------------------------------------------------------------------------------- /doctools/version.py: -------------------------------------------------------------------------------- 1 | short_name = "GodotApplePlugins" 2 | name = "Godot Apple Plugins for MacOS and iOS" 3 | major = 1 4 | minor = 0 5 | patch = 0 6 | status = "dev" 7 | module_config = "" 8 | website = "https://migueldeicaza.github.io/GodotApplePlugins/" 9 | docs = "latest" 10 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/Foundation/Foundation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FoundationUUID.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 12/14/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import Foundation 10 | 11 | @Godot 12 | class Foundation: RefCounted, @unchecked Sendable { 13 | 14 | @Callable static func uuid() -> String { 15 | UUID().uuidString 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /addons/GodotApplePlugins/godot_apple_plugins.gdextension: -------------------------------------------------------------------------------- 1 | [configuration] 2 | 3 | entry_symbol = "godot_apple_plugins_start" 4 | compatibility_minimum = 4.4 5 | 6 | [libraries] 7 | ios = "res://addons/GodotApplePlugins/bin/GodotApplePlugins.xcframework" 8 | macos.arm64 = "res://addons/GodotApplePlugins/bin/GodotApplePlugins.framework" 9 | macos.x86_64 = "res://addons/GodotApplePlugins/bin/GodotApplePlugins_x64.framework" 10 | -------------------------------------------------------------------------------- /doc_classes/SignalProxy.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test-apple-godot-api/project.godot: -------------------------------------------------------------------------------- 1 | ; Engine configuration file. 2 | ; It's best edited using the editor UI and not directly, 3 | ; since the parameters that go here are not all obvious. 4 | ; 5 | ; Format: 6 | ; [section] ; section goes between [] 7 | ; param=value ; assign values to parameters 8 | 9 | config_version=5 10 | 11 | [application] 12 | 13 | config/name="TestAppleGodotApi" 14 | run/main_scene="uid://bo8mk4s2810bd" 15 | config/features=PackedStringArray("4.5", "Forward Plus") 16 | config/icon="res://icon.svg" 17 | 18 | [rendering] 19 | 20 | textures/vram_compression/import_etc2_astc=true 21 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKLeaderboardSet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleLeaderboardSets.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/16/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import SwiftUI 10 | #if canImport(UIKit) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | import GameKit 16 | 17 | @Godot 18 | class GKLeaderboardSet: RefCounted, @unchecked Sendable { 19 | var boardset = GameKit.GKLeaderboardSet() 20 | 21 | convenience init?(boardset: GameKit.GKLeaderboardSet) { 22 | self.init() 23 | self.boardset = boardset 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/AuthenticationServices/ASPasswordCredential.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ASPasswordCredential.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 12/07/25. 6 | // 7 | 8 | import Foundation 9 | import AuthenticationServices 10 | import SwiftGodotRuntime 11 | 12 | @Godot 13 | class ASPasswordCredential: RefCounted, @unchecked Sendable { 14 | var credential: AuthenticationServices.ASPasswordCredential? 15 | 16 | convenience init(credential: AuthenticationServices.ASPasswordCredential) { 17 | self.init() 18 | self.credential = credential 19 | } 20 | 21 | @Export 22 | var user: String { 23 | credential?.user ?? "" 24 | } 25 | 26 | @Export 27 | var password: String { 28 | credential?.password ?? "" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /doc_classes/Foundation.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This class exposes some common APIs from Apple's Foundation framework that can be used elsewhere. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | This creates a Foundation UUID and returns the string representation of it - you can then pass this to methods that expect a UUID. 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/Entitlements.md: -------------------------------------------------------------------------------- 1 | 2 | But generally, what you want is: 3 | 4 | 1. To have a App ID identifier for your project that is configured 5 | with the entitlement, to do this, go to your developer account on 6 | developer.apple.com and navigate to "Certificates, Identifiers & Profiles". 7 | 8 | 2. Create an identifier, or pick an existing identifier 9 | 10 | 3. Make sure that "Game Center" is enabled for that identifier. 11 | 12 | ### Adding the Entitlement to the Godot Mac Editor. 13 | 14 | If you download the Mac binary from the Godot web site, you will need 15 | to add the entitlement to that binary. Since it already comes with a 16 | bundle identifier, you will need to both change the identifier to the 17 | one you created and resign the package. 18 | 19 | TODO: document steps for this, I just asked Claude to do it for me. -------------------------------------------------------------------------------- /doc_classes/GKLeaderboardSet.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Groups related Apple Game Center leaderboards. 5 | 6 | 7 | [code skip-lint]GKLeaderboardSet[/code] mirrors Apple's "leaderboard set" concept. Although the current binding only exposes the opaque object, you can keep references to sets returned by other APIs to organise UI the same way as on iOS. See [url=https://developer.apple.com/documentation/gamekit/gkleaderboardset]Apple's GKLeaderboardSet reference[/url] for the native API guide. 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKGameActivityDefinition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GKGameActivityDefinition.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/20/25. 6 | // 7 | 8 | 9 | @preconcurrency import SwiftGodotRuntime 10 | import SwiftUI 11 | #if canImport(UIKit) 12 | import UIKit 13 | #else 14 | import AppKit 15 | #endif 16 | import GameKit 17 | 18 | @available(iOS 26.0, macOS 26.0, *) 19 | @Godot 20 | class GKGameActivityDefinition: RefCounted, @unchecked Sendable { 21 | var definition: GameKit.GKGameActivityDefinition? 22 | 23 | @Export var title: String { definition?.title ?? ""} 24 | 25 | @Export var details: String { definition?.details ?? "" } 26 | 27 | @Export var defaultProperties: TypedDictionary { 28 | get { 29 | return TypedDictionary() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/Shared/OSIntegration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OSIntegration.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/17/25. 6 | // 7 | import SwiftGodotRuntime 8 | import Foundation 9 | 10 | extension PackedByteArray { 11 | public func asData() -> Data? { 12 | return withUnsafeAccessToData { ptr, count in Data (bytes: ptr, count: count) } 13 | } 14 | } 15 | 16 | extension Data { 17 | public func toPackedByteArray() -> PackedByteArray { 18 | let byteArray = [UInt8](self) 19 | return PackedByteArray(byteArray) 20 | } 21 | } 22 | 23 | /// Wraps a platform error into a variant with the result, for now we send back a string, but we could provide another version 24 | func mapError(_ error: (any Error)?) -> Variant? { 25 | if let error { 26 | return Variant(error.localizedDescription) 27 | } 28 | return nil 29 | } 30 | -------------------------------------------------------------------------------- /doc_classes/ASPasswordCredential.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents a password credential. 5 | 6 | 7 | This class contains a user identifier and password, typically retrieved from the iCloud Keychain when the user selects a saved password. 8 | 9 | 10 | 11 | 12 | 13 | The user's password. 14 | 15 | 16 | The user identifier associated with the credential. 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /test-apple-godot-api/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /doc_classes/StoreProductSubscriptionPeriod.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Values that represent the duration of time between subscription renewals. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | A subscription period of one day. 13 | 14 | 15 | A subscription period of one month. 16 | 17 | 18 | A subscription period of one week. 19 | 20 | 21 | A subscription period of one year. 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "126f1eeae575e220ab96356bf02174bf3d3202a2123676f4fe741b9690ae7c8b", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-argument-parser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-argument-parser", 8 | "state" : { 9 | "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", 10 | "version" : "1.7.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-syntax", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/swiftlang/swift-syntax", 17 | "state" : { 18 | "revision" : "0687f71944021d616d34d922343dcef086855920", 19 | "version" : "600.0.1" 20 | } 21 | }, 22 | { 23 | "identity" : "swiftgodot", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/migueldeicaza/SwiftGodot", 26 | "state" : { 27 | "revision" : "61f258c8a679ca8e2b637befb77daf1a640a5349" 28 | } 29 | } 30 | ], 31 | "version" : 3 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Miguel de Icaza (https://github.com/migueldeicaza) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test-apple-godot-api/icon.svg.import: -------------------------------------------------------------------------------- 1 | [remap] 2 | 3 | importer="texture" 4 | type="CompressedTexture2D" 5 | uid="uid://cbxsxk1je7s3g" 6 | path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" 7 | metadata={ 8 | "vram_texture": false 9 | } 10 | 11 | [deps] 12 | 13 | source_file="res://icon.svg" 14 | dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] 15 | 16 | [params] 17 | 18 | compress/mode=0 19 | compress/high_quality=false 20 | compress/lossy_quality=0.7 21 | compress/uastc_level=0 22 | compress/rdo_quality_loss=0.0 23 | compress/hdr_compression=1 24 | compress/normal_map=0 25 | compress/channel_pack=0 26 | mipmaps/generate=false 27 | mipmaps/limit=-1 28 | roughness/mode=0 29 | roughness/src_normal="" 30 | process/channel_remap/red=0 31 | process/channel_remap/green=1 32 | process/channel_remap/blue=2 33 | process/channel_remap/alpha=3 34 | process/fix_alpha_border=true 35 | process/premult_alpha=false 36 | process/normal_map_invert_y=false 37 | process/hdr_as_srgb=false 38 | process/hdr_clamp_exposure=false 39 | process/size_limit=0 40 | detect_3d/compress_to=1 41 | svg/scale=1.0 42 | editor/scale_with_editor_scale=false 43 | editor/convert_colors_with_editor_theme=false 44 | -------------------------------------------------------------------------------- /doc_classes/StoreProductSubscriptionOffer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents a subscription offer for a [StoreProduct]. 5 | 6 | 7 | You configure these in AppStore Connect. See [url=https://developer.apple.com/documentation/storekit/product/subscriptionoffer]Apple's documentation[/url] for additional information 8 | 9 | 10 | 11 | 12 | 13 | An introductory offer for new subscribers. 14 | 15 | 16 | A promotional offer for existing or lapsed subscribers. 17 | 18 | 19 | A win-back offer for lapsed subscribers. 20 | 21 | 22 | An unknown offer type. 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /doc_classes/StoreView.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A view that displays a collection of products. 5 | 6 | 7 | This class represents a view that displays multiple products from the App Store. It is useful for creating a storefront or a list of available items. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Dismisses the currently presented view. 16 | 17 | 18 | 19 | 20 | 21 | Presents the store view on top of the current view hierarchy. 22 | 23 | 24 | 25 | 26 | 27 | A list of product identifiers to display in the store view. 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /doc_classes/GKLeaderboardEntry.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Single score information on the leaderboard. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | An integer value that your game uses. 13 | 14 | 15 | The player’s score as a localized string. 16 | 17 | 18 | The player who earns the score. 19 | 20 | 21 | The position of the score in the results of a leaderboard search. 22 | 23 | 24 | The score that the player earns. 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKSavedGame.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GKSavedGame.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/20/25. 6 | // 7 | @preconcurrency import SwiftGodotRuntime 8 | import SwiftUI 9 | #if canImport(UIKit) 10 | import UIKit 11 | #else 12 | import AppKit 13 | #endif 14 | 15 | import GameKit 16 | 17 | @Godot 18 | class GKSavedGame: GKPlayer, @unchecked Sendable { 19 | var saved: GameKit.GKSavedGame? 20 | 21 | convenience init(saved: GameKit.GKSavedGame) { 22 | self.init() 23 | self.saved = saved 24 | } 25 | 26 | @Export var name: String { 27 | saved?.name ?? "" 28 | } 29 | 30 | @Export var deviceName: String { 31 | saved?.deviceName ?? "" 32 | } 33 | 34 | @Callable 35 | func load_data(done: Callable) { 36 | guard let saved else { 37 | _ = done.call(Variant(PackedByteArray()), Variant(String("GKSavedGame: Instance was not setup"))) 38 | return 39 | } 40 | saved.loadData { data, error in 41 | var ret: Variant 42 | if let data { 43 | ret = Variant(data.toPackedByteArray()) 44 | } else { 45 | ret = Variant(PackedByteArray()) 46 | } 47 | _ = done.call(ret, mapError(error)) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let swiftSettings: [SwiftSetting] = [ 7 | .unsafeFlags([ 8 | "-Xfrontend", "-internalize-at-link", 9 | "-Xfrontend", "-lto=llvm-full", 10 | "-Xfrontend", "-conditional-runtime-records" 11 | ]) 12 | ] 13 | 14 | let linkerSettings: [LinkerSetting] = [ 15 | .unsafeFlags(["-Xlinker", "-dead_strip"]) 16 | ] 17 | 18 | let package = Package( 19 | name: "GodotApplePlugins", 20 | platforms: [ 21 | .iOS(.v17), 22 | .macOS("14.0"), 23 | ], 24 | products: [ 25 | .library( 26 | name: "GodotApplePlugins", 27 | type: .dynamic, 28 | targets: ["GodotApplePlugins"] 29 | ), 30 | ], 31 | dependencies: [ 32 | .package(url: "https://github.com/migueldeicaza/SwiftGodot", revision: "61f258c8a679ca8e2b637befb77daf1a640a5349") 33 | // For local development: 34 | //.package(path: "../SwiftGodot") 35 | ], 36 | targets: [ 37 | .target( 38 | name: "GodotApplePlugins", 39 | dependencies: [ 40 | .product(name: "SwiftGodotRuntimeStatic", package: "SwiftGodot") 41 | ], 42 | swiftSettings: swiftSettings, 43 | linkerSettings: linkerSettings 44 | ), 45 | ] 46 | ) 47 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/StoreKit/StoreTransaction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoreTransaction.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/21/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import StoreKit 10 | 11 | @Godot 12 | class StoreTransaction: RefCounted, @unchecked Sendable { 13 | var transaction: Transaction? 14 | 15 | convenience init(_ transaction: Transaction) { 16 | self.init() 17 | self.transaction = transaction 18 | } 19 | 20 | @Export var transactionId: Int { Int(bitPattern: UInt(transaction?.id ?? 0)) } 21 | @Export var originalID: Int { Int(bitPattern: UInt(transaction?.originalID ?? 0)) } 22 | @Export var productID: String { transaction?.productID ?? "" } 23 | @Export var purchaseDate: Double { transaction?.purchaseDate.timeIntervalSince1970 ?? 0 } 24 | @Export var expirationDate: Double { transaction?.expirationDate?.timeIntervalSince1970 ?? 0 } 25 | @Export var revocationDate: Double { transaction?.revocationDate?.timeIntervalSince1970 ?? 0 } 26 | @Export var isUpgraded: Bool { transaction?.isUpgraded ?? false } 27 | 28 | @Export var ownershipType: String { 29 | guard let transaction else { return "unknown" } 30 | switch transaction.ownershipType { 31 | case .purchased: return "purchased" 32 | case .familyShared: return "familyShared" 33 | default: return "unknown" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /doc_classes/GKSavedGame.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents a saved game 5 | 6 | 7 | Instances of this class are returned after you save a game with GKLocalPlayer's save_game_data, or 8 | after you load the game data with GKLocalPlayer fetch_saved_games 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | Loads the game data, and invokes the provided callbacj with the first argument 18 | being a PackedByteArray and the second argument being a Variant encoding an error, 19 | if not-nil it contains a string description of the problem. 20 | 21 | 22 | 23 | 24 | 25 | Name of the saved game. 26 | 27 | 28 | The name of the device where the player saved the game. 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/StoreKit/StoreView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoreView.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/21/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import StoreKit 10 | import SwiftUI 11 | 12 | @Godot 13 | class StoreView: RefCounted, @unchecked Sendable { 14 | @Export var productIds: PackedStringArray = PackedStringArray() 15 | 16 | 17 | @Callable 18 | func present() { 19 | guard productIds.count > 0 else { return } 20 | 21 | var ids: [String] = [] 22 | for id in productIds { 23 | ids.append(id) 24 | } 25 | 26 | Task { @MainActor in 27 | let view = StoreKit.StoreView(ids: ids) { product in 28 | Image(systemName: "cart") 29 | } 30 | 31 | let wrappedView = NavigationView { 32 | view 33 | .toolbar { 34 | ToolbarItem(placement: .cancellationAction) { 35 | Button("Close") { 36 | dismissTopView() 37 | } 38 | } 39 | } 40 | } 41 | 42 | presentView(wrappedView) 43 | } 44 | } 45 | 46 | @Callable 47 | func dismiss() { 48 | Task { @MainActor in 49 | dismissTopView() 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /doc_classes/StoreProductPaymentMode.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The payment modes for subscription offers that apply to a transaction. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | Returns a payment mode representing a free trial. 15 | 16 | 17 | 18 | 19 | 20 | Returns a payment mode where the user pays as they go (e.g. monthly for 3 months). 21 | 22 | 23 | 24 | 25 | 26 | Returns a payment mode where the user pays the entire amount up front. 27 | 28 | 29 | 30 | 31 | 32 | The localized description of the payment mode. 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKMatchRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GKMatchRequest.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/17/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import SwiftUI 10 | #if canImport(UIKit) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | import GameKit 16 | 17 | @Godot 18 | class GKMatchRequest: RefCounted, @unchecked Sendable { 19 | var request = GameKit.GKMatchRequest() 20 | 21 | enum MatchType: Int, CaseIterable { 22 | case peerToPeer 23 | case hosted 24 | case turnBased 25 | func toGameKit() -> GameKit.GKMatchType { 26 | switch self { 27 | case .peerToPeer: return .peerToPeer 28 | case .hosted: return .hosted 29 | case .turnBased: return .turnBased 30 | } 31 | } 32 | } 33 | 34 | @Export var minPlayers: Int { 35 | get { request.minPlayers } 36 | set { request.minPlayers = newValue } 37 | } 38 | 39 | @Export var maxPlayers: Int { 40 | get { request.maxPlayers } 41 | set { request.maxPlayers = newValue } 42 | } 43 | @Export var defaultNumberOfPlayers: Int { 44 | get { request.defaultNumberOfPlayers } 45 | set { request.defaultNumberOfPlayers = newValue } 46 | } 47 | 48 | @Callable 49 | static func max_players_allowed_for_match(forType: MatchType) -> Int { 50 | return GameKit.GKMatchRequest.maxPlayersAllowedForMatch(of: forType.toGameKit()) 51 | } 52 | 53 | @Export var inviteMessage: String { 54 | get { request.inviteMessage ?? "" } 55 | set { request.inviteMessage = newValue } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /doc_classes/SubscriptionOfferView.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A view for redeeming subscription offers. 5 | 6 | 7 | This class provides a view for users to redeem subscription offer codes. It handles the UI flow for entering and verifying offer codes. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Dismisses the currently presented view. 16 | 17 | 18 | 19 | 20 | 21 | 22 | Presents the offer code redemption view. note: The `callback` parameter is currently unused; use the [signal success] and [signal error] signals to handle the result. 23 | 24 | 25 | 26 | 27 | 28 | The title displayed on the view. 29 | 30 | 31 | 32 | 33 | 34 | 35 | Emitted when the offer redemption fails. `arg1` contains the error message. 36 | 37 | 38 | 39 | 40 | Emitted when the offer redemption is successful. 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /test-apple-godot-api/control.tscn: -------------------------------------------------------------------------------- 1 | [gd_scene load_steps=3 format=3 uid="uid://bo8mk4s2810bd"] 2 | 3 | [ext_resource type="Script" uid="uid://k565oj0kotti" path="res://control.gd" id="1_0fbet"] 4 | [ext_resource type="Texture2D" uid="uid://cbxsxk1je7s3g" path="res://icon.svg" id="2_62e2m"] 5 | 6 | [node name="Control" type="Control"] 7 | layout_mode = 3 8 | anchors_preset = 15 9 | anchor_right = 1.0 10 | anchor_bottom = 1.0 11 | grow_horizontal = 2 12 | grow_vertical = 2 13 | script = ExtResource("1_0fbet") 14 | 15 | [node name="Button" type="Button" parent="."] 16 | layout_mode = 0 17 | offset_left = 64.0 18 | offset_top = 99.0 19 | offset_right = 272.0 20 | offset_bottom = 152.0 21 | theme_override_font_sizes/font_size = 32 22 | text = "Authenticate" 23 | 24 | [node name="auth_result" type="Label" parent="."] 25 | layout_mode = 0 26 | offset_left = 71.0 27 | offset_top = 168.0 28 | offset_right = 693.0 29 | offset_bottom = 213.0 30 | theme_override_font_sizes/font_size = 32 31 | text = "Auth Error:" 32 | 33 | [node name="auth_state" type="Label" parent="."] 34 | layout_mode = 0 35 | offset_left = 70.0 36 | offset_top = 236.0 37 | offset_right = 307.0 38 | offset_bottom = 281.0 39 | theme_override_font_sizes/font_size = 32 40 | text = "Authenticated: " 41 | 42 | [node name="texture_rect" type="TextureRect" parent="."] 43 | layout_mode = 0 44 | offset_left = 599.0 45 | offset_top = 46.0 46 | offset_right = 967.0 47 | offset_bottom = 494.0 48 | texture = ExtResource("2_62e2m") 49 | 50 | [node name="button_requestmatch" type="Button" parent="."] 51 | layout_mode = 0 52 | offset_left = 73.0 53 | offset_top = 342.0 54 | offset_right = 197.0 55 | offset_bottom = 373.0 56 | text = "Request Match" 57 | 58 | [node name="Node3D" type="Node3D" parent="button_requestmatch"] 59 | 60 | [connection signal="pressed" from="Button" to="." method="_on_button_pressed"] 61 | [connection signal="pressed" from="button_requestmatch" to="." method="_on_button_requestmatch_pressed"] 62 | -------------------------------------------------------------------------------- /doc_classes/StoreProduct.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents a product available for purchase in the App Store. 5 | 6 | 7 | This class contains information about a product, such as its ID, display name, description, and price. It is returned by [method StoreKitManager.request_products]. 8 | 9 | 10 | 11 | 12 | 13 | The description of the product, used for display in the UI. 14 | 15 | 16 | The localized display name of the product. 17 | 18 | 19 | The formatted price of the product, including the currency symbol (e.g. "$0.99"). 20 | 21 | 22 | Indicates whether the product can be shared with family members. 23 | 24 | 25 | A JSON debug representation of the product. 26 | 27 | 28 | The price of the product in the local currency. 29 | 30 | 31 | The unique identifier for the product. 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GameCenter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GameCenter.swift 3 | // SwiftGodotAppleTemplate 4 | // 5 | // Created by Miguel de Icaza on 11/15/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import SwiftUI 10 | #if canImport(UIKit) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | 16 | import GameKit 17 | 18 | @Godot 19 | class GameCenterManager: RefCounted, @unchecked Sendable { 20 | @Signal("message") var authentication_error: SignalWithArguments 21 | @Signal("status") var authentication_result: SignalWithArguments 22 | 23 | var isAuthenticated: Bool = false 24 | 25 | @Export var localPlayer: GKLocalPlayer 26 | 27 | required init(_ context: InitContext) { 28 | localPlayer = GKLocalPlayer() 29 | super.init(context) 30 | } 31 | 32 | @Callable 33 | func authenticate() { 34 | let localPlayer = GameKit.GKLocalPlayer.local 35 | localPlayer.authenticateHandler = { viewController, error in 36 | MainActor.assumeIsolated { 37 | if let vc = viewController { 38 | presentOnTop(vc) 39 | return 40 | } 41 | 42 | if let error = error { 43 | self.authentication_error.emit(String(describing: error)) 44 | } 45 | self.isAuthenticated = GameKit.GKLocalPlayer.local.isAuthenticated 46 | self.authentication_result.emit(self.isAuthenticated) 47 | } 48 | } 49 | } 50 | } 51 | 52 | #if standalone 53 | #initSwiftExtension(cdecl: "godot_game_center_init", types: [ 54 | GameCenterManager.self, 55 | GKAchievement.self, 56 | GKAchievementDescription.self, 57 | GKLocalPlayer.self, 58 | GKLeaderboard.self, 59 | GKLeaderboardSet.self, 60 | GKMatch.self, 61 | GKMatchmakerViewController.self, 62 | GKMatchRequest.self, 63 | GKPlayer.self, 64 | ]) 65 | #endif 66 | -------------------------------------------------------------------------------- /doc_classes/StoreTransaction.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents a successful purchase transaction. 5 | 6 | 7 | This class contains details about a purchase, such as the product ID, purchase date, and expiration date (for subscriptions). 8 | 9 | 10 | 11 | 12 | 13 | The date when the subscription expires, as a Unix timestamp. Returns 0 if not applicable. 14 | 15 | 16 | Whether this transaction is an upgrade of another transaction. 17 | 18 | 19 | The transaction identifier of the original purchase. 20 | 21 | 22 | The type of ownership (e.g., "purchased", "familyShared", "unknown"). 23 | 24 | 25 | The unique identifier of the purchased product. 26 | 27 | 28 | The date when the purchase was made, as a Unix timestamp. 29 | 30 | 31 | The date when the transaction was revoked, as a Unix timestamp. Returns 0 if not revoked. 32 | 33 | 34 | The unique identifier of the transaction. 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/StoreKit/ProductView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductView.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/21/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import StoreKit 10 | import SwiftUI 11 | 12 | @Godot 13 | class ProductView: RefCounted, @unchecked Sendable { 14 | @Export var productId: String = "" 15 | @Export var prefersPromotionalIcon: Bool = false 16 | @Export var systemIconName: String = "cart" 17 | 18 | // Enum for ProductViewStyle 19 | enum ViewStyle: Int, CaseIterable { 20 | case automatic = 0 21 | case compact = 1 22 | case large = 2 23 | case regular = 3 24 | } 25 | 26 | @Export(.enum) var style: ViewStyle = .automatic 27 | 28 | @Callable 29 | func present() { 30 | guard !productId.isEmpty else { return } 31 | 32 | Task { @MainActor in 33 | let view = StoreKit.ProductView(id: productId) { 34 | Image(systemName: systemIconName) 35 | } 36 | 37 | switch style { 38 | case .automatic: 39 | self.presentWrapped(view.productViewStyle(.automatic)) 40 | case .compact: 41 | self.presentWrapped(view.productViewStyle(.compact)) 42 | case .large: 43 | self.presentWrapped(view.productViewStyle(.large)) 44 | case .regular: 45 | self.presentWrapped(view.productViewStyle(.regular)) 46 | } 47 | } 48 | } 49 | 50 | @MainActor 51 | private func presentWrapped(_ view: V) { 52 | let wrappedView = NavigationView { 53 | view 54 | .toolbar { 55 | ToolbarItem(placement: .cancellationAction) { 56 | Button("Close") { 57 | dismissTopView() 58 | } 59 | } 60 | } 61 | } 62 | presentView(wrappedView) 63 | } 64 | 65 | @Callable 66 | func dismiss() { 67 | Task { @MainActor in 68 | dismissTopView() 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ApplePlayer.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/15/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import SwiftUI 10 | #if canImport(UIKit) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | 16 | import GameKit 17 | 18 | @Godot 19 | class GKPlayer: RefCounted, @unchecked Sendable { 20 | var player: GameKit.GKPlayer = GameKit.GKPlayer() 21 | 22 | init(player: GameKit.GKPlayer) { 23 | self.player = player 24 | guard let ctxt = InitContext.createObject(className: GKPlayer.godotClassName) else { 25 | fatalError("Could not create object") 26 | } 27 | super.init(ctxt) 28 | 29 | } 30 | 31 | required init(_ context: InitContext) { 32 | player = GameKit.GKPlayer() 33 | super.init(context) 34 | } 35 | 36 | @Export var gamePlayerID: String { player.gamePlayerID } 37 | @Export var teamPlayerID: String { player.teamPlayerID } 38 | @Export var alias: String { player.alias } 39 | @Export var displayName: String { player.displayName } 40 | @Export var isInvitable: Bool { player.isInvitable } 41 | 42 | @Callable 43 | func scopedIDsArePersistent() -> Bool { 44 | player.scopedIDsArePersistent() 45 | } 46 | 47 | /// Callback is invoked with two parameters: 48 | /// (imageData: Image, erro: String) 49 | /// 50 | /// One of those two is nil. 51 | @Callable 52 | func load_photo(_ small: Bool, _ callback: Callable) { 53 | player.loadPhoto(for: small ? .small : .normal) { img, error in 54 | DispatchQueue.main.async { 55 | if let img { 56 | if let godotImage = img.asGodotImage() { 57 | _ = callback.call(Variant(godotImage), nil) 58 | } else { 59 | _ = callback.call(nil, Variant(String("Could not convert image"))) 60 | } 61 | } 62 | if let error { 63 | _ = callback.call(nil, Variant(String(describing: error))) 64 | return 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GodotApplePlugins.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GodotApplePlugins.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/14/25. 6 | // 7 | 8 | import SwiftGodotRuntime 9 | 10 | #initSwiftExtension( 11 | cdecl: "godot_apple_plugins_start", 12 | types: [ 13 | AVAudioSession.self, 14 | 15 | Foundation.self, 16 | AppleURL.self, 17 | 18 | GameCenterManager.self, 19 | GKAchievement.self, 20 | GKAchievementDescription.self, 21 | GKGameCenterViewController.self, 22 | GKLocalPlayer.self, 23 | GKLeaderboard.self, 24 | GKLeaderboardEntry.self, 25 | GKLeaderboardSet.self, 26 | GKMatch.self, 27 | GKMatchmakerViewController.self, 28 | GKMatchRequest.self, 29 | GKPlayer.self, 30 | GKSavedGame.self, 31 | 32 | ProductView.self, 33 | StoreKitManager.self, 34 | StoreProduct.self, 35 | StoreProductPurchaseOption.self, 36 | StoreProductSubscriptionOffer.self, 37 | StoreProductPaymentMode.self, 38 | StoreProductSubscriptionPeriod.self, 39 | StoreTransaction.self, 40 | StoreView.self, 41 | SubscriptionOfferView.self, 42 | SubscriptionStoreView.self, 43 | 44 | AppleFilePicker.self, 45 | 46 | ASAuthorizationAppleIDCredential.self, 47 | ASPasswordCredential.self, 48 | ASAuthorizationController.self 49 | ], 50 | enums: [ 51 | AVAudioSession.SessionCategory.self, 52 | 53 | GKGameCenterViewController.State.self, 54 | GKLeaderboard.AppleLeaderboardType.self, 55 | GKLeaderboard.TimeScope.self, 56 | GKLeaderboard.PlayerScope.self, 57 | GKMatch.SendDataMode.self, 58 | GKMatchRequest.MatchType.self, 59 | 60 | ProductView.ViewStyle.self, 61 | StoreKitManager.StoreKitStatus.self, 62 | SubscriptionStoreView.ControlStyle.self, 63 | StoreProductSubscriptionOffer.OfferType.self, 64 | StoreProductSubscriptionPeriod.Unit.self, 65 | 66 | ASAuthorizationAppleIDCredential.UserDetectionStatus.self, 67 | ASAuthorizationAppleIDCredential.UserAgeRange.self 68 | ], 69 | registerDocs: true 70 | ) 71 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/Shared/AppKitIntegration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppKitIntegration.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/15/25. 6 | // 7 | 8 | #if canImport(AppKit) 9 | import AppKit 10 | import SwiftGodotRuntime 11 | 12 | @MainActor 13 | func presentOnTop(_ vc: NSViewController) { 14 | guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { 15 | // Fallback: frontmost window if needed 16 | NSApp.activate(ignoringOtherApps: true) 17 | return 18 | } 19 | if let cv = window.contentViewController { 20 | cv.presentAsSheet(vc) 21 | } else { 22 | let panel = NSWindow( 23 | contentViewController: vc 24 | ) 25 | 26 | panel.styleMask = [.titled, .resizable, .closable] 27 | panel.level = .floating 28 | panel.isReleasedWhenClosed = false 29 | panel.isReleasedWhenClosed = true 30 | 31 | panel.makeKeyAndOrderFront(nil) 32 | } 33 | } 34 | 35 | extension NSImage { 36 | func pngData() -> Data? { 37 | // Try via TIFF representation first (works for many NSImage sources) 38 | if let tiff = self.tiffRepresentation, 39 | let rep = NSBitmapImageRep(data: tiff), 40 | let data = rep.representation(using: .png, properties: [:]) { 41 | return data 42 | } 43 | 44 | // Fallback: attempt via CGImage-backed rep 45 | var proposedRect = NSRect(origin: .zero, size: self.size) 46 | if let cgImage = self.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) { 47 | let rep = NSBitmapImageRep(cgImage: cgImage) 48 | return rep.representation(using: .png, properties: [:]) 49 | } 50 | 51 | return nil 52 | } 53 | 54 | func asGodotImage() -> Variant? { 55 | guard let png = self.pngData() else { return nil } 56 | let array = PackedByteArray([UInt8](png)) 57 | if let image = ClassDB.instantiate(class: "Image") { 58 | switch image.call(method: "load_png_from_buffer", Variant(array)) { 59 | case .success(_): 60 | return Variant(image) 61 | case .failure(_): 62 | return nil 63 | } 64 | } 65 | return nil 66 | } 67 | } 68 | #endif 69 | -------------------------------------------------------------------------------- /doc_classes/StoreProductPurchaseOption.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents an option that can be supplied when purchasing a [StoreProduct]. 5 | 6 | 7 | You create instances of this class and you can pass those to [method purchase_with_options]. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | Creates a purchase option with an app account token. This token should be a UUID string that associates the transaction with a user account on your service. 17 | 18 | 19 | 20 | 21 | 22 | 23 | Creates a purchase option with a specific introductory offer eligibility. This requires the signed JWS string from the App Store. 24 | 25 | 26 | 27 | 28 | 29 | 30 | Creates a purchase option specifying the quantity of the product to purchase. 31 | 32 | 33 | 34 | 35 | 36 | 37 | Creates a purchase option to simulate the "Ask to Buy" flow in the sandbox environment. 38 | 39 | 40 | 41 | 42 | 43 | 44 | Creates a purchase option for a win-back offer. 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/AVFoundation/GodotAVAudioSession.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GodotAVAudioSession.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/15/25. 6 | // 7 | import SwiftGodotRuntime 8 | 9 | extension AVAudioSession { 10 | enum SessionCategory: Int64, CaseIterable { 11 | case ambient 12 | case multiRoute 13 | case playAndRecord 14 | case playback 15 | case record 16 | case soloAmbient 17 | case unknown 18 | } 19 | } 20 | #if os(iOS) || os(tvOS) || os(visionOS) 21 | import AVFoundation 22 | 23 | extension AVAudioSession.SessionCategory { 24 | func toAVAudioSessionCategory() -> AVFoundation.AVAudioSession.Category { 25 | switch self { 26 | case .ambient: 27 | return .ambient 28 | case .multiRoute: 29 | return .multiRoute 30 | case .playAndRecord: 31 | return .playAndRecord 32 | case .playback: 33 | return .playback 34 | case .record: 35 | return .record 36 | case .soloAmbient: 37 | return .soloAmbient 38 | case .unknown: 39 | return .ambient 40 | } 41 | } 42 | } 43 | 44 | @Godot 45 | public class AVAudioSession: RefCounted, @unchecked Sendable { 46 | @Export var category: SessionCategory { 47 | get { 48 | switch AVFoundation.AVAudioSession.sharedInstance().category { 49 | case .ambient: 50 | return .ambient 51 | case .multiRoute: 52 | return .multiRoute 53 | case .playback: 54 | return .playback 55 | case .playAndRecord: 56 | return .playAndRecord 57 | case .soloAmbient: 58 | return .soloAmbient 59 | default: 60 | return .unknown 61 | } 62 | } 63 | set { 64 | try? AVFoundation.AVAudioSession.sharedInstance().setCategory(newValue.toAVAudioSessionCategory()) 65 | } 66 | } 67 | } 68 | #else 69 | @Godot 70 | public class AVAudioSession: RefCounted, @unchecked Sendable { 71 | @Export var category: SessionCategory { 72 | get { 73 | return .unknown 74 | } 75 | set { 76 | // ignore 77 | } 78 | } 79 | } 80 | #endif 81 | -------------------------------------------------------------------------------- /doc_classes/ProductView.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A view that displays a single product from the App Store. 5 | 6 | 7 | This class represents a view that displays a single product from the App Store. It allows you to configure the product to display, the style of the view, and the icon to use. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Dismisses the currently presented view. 16 | 17 | 18 | 19 | 20 | 21 | Presents the product view on top of the current view hierarchy. 22 | 23 | 24 | 25 | 26 | 27 | Whether the view prefers to show the promotional icon for the product. 28 | 29 | 30 | The product identifier of the product to display. 31 | 32 | 33 | The style of the product view. See [enum ViewStyle] for available options. 34 | 35 | 36 | The name of the system icon to use as a placeholder while the product icon loads. 37 | 38 | 39 | 40 | 41 | The system automatically chooses the most appropriate style. 42 | 43 | 44 | A compact style that displays minimal information. 45 | 46 | 47 | A large style that displays more detailed information. 48 | 49 | 50 | The regular style. 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /doc_classes/AppleFilePicker.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Provides access to the operating system file picker, on iOS, this provides access to sandboxed content. 4 | 5 | 6 | This class provides access to the system's native file picker (`UIDocumentPickerViewController` on iOS, `NSOpenPanel` on macOS). 7 | It allows selecting files based on UTTypes (passed as file extensions) and supports both single and multiple file selection. 8 | 9 | If your application is running in a sandbox (always the case on iOS, sometimes on MacOS), you need to call 10 | the AppleURL methods for starting and stopping access to them. 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Opens the file picker. 21 | [param allowed_types] should be an array of strings representing the file extensions or UTTypes you want to allow (e.g. `["txt", "png"]` or `["public.plain-text"]`). 22 | [param allow_multiple] if true, allows selecting multiple files. Results will be emitted via the [signal files_selected] signal. If false (default), results are emitted via [signal file_selected]. 23 | 24 | 25 | 26 | 27 | 28 | 29 | Emitted when the picker is canceled by the user. 30 | 31 | 32 | 33 | 34 | 35 | 36 | Emitted when a single file is selected (if [param allow_multiple] was false). 37 | 38 | 39 | 40 | 41 | 42 | 43 | Emitted when multiple files are selected (if [param allow_multiple] was true). 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /doc_classes/AVAudioSession.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Controls the shared iOS/tvOS audio session from GDScript. 5 | 6 | 7 | Use this helper to select one of Apple's predefined [code skip-lint]AVAudioSession[/code] categories so your Godot project can mix or duck audio according to platform expectations. It is most useful on mobile builds where the engine needs to share the audio device with the system music player or with voice chat as described in [code skip-lint]GameCenterGuide.md[/code]. See Apple's audio session overview at [url=https://developer.apple.com/documentation/avfaudio/avaudiosession]Apple's AVAudioSession reference[/url]. 8 | 9 | 10 | 11 | 12 | 13 | Selects the Apple audio session category. Use one of the [enum SessionCategory] constants to decide whether your game mixes with other apps, records audio, or routes voice chat. 14 | 15 | 16 | 17 | 18 | Plays audio quietly and mixes with other apps, ideal for games that should respect background music. 19 | 20 | 21 | Allows simultaneous input and output on multiple ports such as iPad USB headsets and speakers. 22 | 23 | 24 | Enables full duplex audio for gameplay that records the microphone while playing effects. 25 | 26 | 27 | Optimised for playback only and silences system music while your game is active. 28 | 29 | 30 | Recording-only mode for utilities that only capture audio. 31 | 32 | 33 | Similar to [constant ambient] but pauses other audio sessions when your project starts. 34 | 35 | 36 | Default fallback when the platform does not expose audio sessions (macOS desktop). 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/StoreKit/SubscriptionPeriod.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubscriptionPeriod.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 12/14/25. 6 | // 7 | 8 | 9 | @preconcurrency import SwiftGodotRuntime 10 | import StoreKit 11 | import SwiftUI 12 | 13 | class StoreProductSubscriptionPeriod: RefCounted, @unchecked Sendable { 14 | var period: Product.SubscriptionPeriod? 15 | convenience init(_ period: Product.SubscriptionPeriod) { 16 | self.init() 17 | self.period = period 18 | } 19 | 20 | enum Unit: Int, CaseIterable { 21 | case day 22 | case month 23 | case week 24 | case year 25 | } 26 | @Export var value: Int { period?.value ?? 0 } 27 | @Export var unit: Unit { 28 | switch period?.unit { 29 | case .day: return .day 30 | case .month: return .month 31 | case .week: return .week 32 | case .year: return .year 33 | // should not happen, but to make the compiler happy 34 | default: return .day 35 | } 36 | } 37 | @Export var unitLocalized: String { period?.unit.localizedDescription ?? "" } 38 | 39 | @Callable static func get_every_six_months() -> StoreProductSubscriptionPeriod { 40 | StoreProductSubscriptionPeriod(Product.SubscriptionPeriod.everySixMonths) 41 | } 42 | @Callable static func get_every_three_days() -> StoreProductSubscriptionPeriod { 43 | StoreProductSubscriptionPeriod(Product.SubscriptionPeriod.everyThreeDays) 44 | } 45 | @Callable static func get_every_three_months() -> StoreProductSubscriptionPeriod { 46 | StoreProductSubscriptionPeriod(Product.SubscriptionPeriod.everyThreeMonths) 47 | } 48 | @Callable static func get_every_two_months() -> StoreProductSubscriptionPeriod { 49 | StoreProductSubscriptionPeriod(Product.SubscriptionPeriod.everyTwoMonths) 50 | } 51 | @Callable static func get_every_two_weeks() -> StoreProductSubscriptionPeriod { 52 | StoreProductSubscriptionPeriod(Product.SubscriptionPeriod.everyTwoWeeks) 53 | } 54 | @Callable static func get_monthly() -> StoreProductSubscriptionPeriod { 55 | StoreProductSubscriptionPeriod(Product.SubscriptionPeriod.monthly) 56 | } 57 | @Callable static func get_weekly() -> StoreProductSubscriptionPeriod { 58 | StoreProductSubscriptionPeriod(Product.SubscriptionPeriod.weekly) 59 | } 60 | @Callable static func get_yearly() -> StoreProductSubscriptionPeriod { 61 | StoreProductSubscriptionPeriod(Product.SubscriptionPeriod.yearly) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/Shared/UIKitIntegration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIKitIntegration.swift 3 | // SwiftGodotAppleTemplate 4 | // 5 | // Created by Miguel de Icaza on 11/14/25. 6 | // 7 | #if canImport(UIKit) 8 | import UIKit 9 | import SwiftGodotRuntime 10 | 11 | extension UIApplication { 12 | var activeWindowScene: UIWindowScene? { 13 | connectedScenes 14 | .compactMap { $0 as? UIWindowScene } 15 | .first { $0.activationState == .foregroundActive } 16 | } 17 | 18 | var keyWindow: UIWindow? { 19 | // Preferred for iOS 13+ 20 | if let scene = activeWindowScene { 21 | return scene.windows.first { $0.isKeyWindow } 22 | } 23 | // Fallback (older / weird cases) 24 | return windows.first { $0.isKeyWindow } 25 | } 26 | 27 | var topMostViewController: UIViewController? { 28 | guard let root = keyWindow?.rootViewController else { return nil } 29 | return root.mostVisibleViewController 30 | } 31 | } 32 | 33 | extension UIViewController { 34 | /// Recursively find the "most visible" child or presented controller 35 | var mostVisibleViewController: UIViewController { 36 | if let nav = self as? UINavigationController { 37 | return nav.visibleViewController?.mostVisibleViewController ?? nav 38 | } 39 | if let tab = self as? UITabBarController { 40 | return tab.selectedViewController?.mostVisibleViewController ?? tab 41 | } 42 | if let presented = presentedViewController { 43 | return presented.mostVisibleViewController 44 | } 45 | return self 46 | } 47 | } 48 | 49 | extension UIImage { 50 | func asGodotImage() -> Variant? { 51 | guard let png = self.pngData() else { return nil } 52 | let array = PackedByteArray([UInt8](png)) 53 | if let image = ClassDB.instantiate(class: "Image") { 54 | switch image.call(method: "load_png_from_buffer", Variant(array)) { 55 | case .success(_): 56 | return Variant(image) 57 | case .failure(_): 58 | return nil 59 | } 60 | } 61 | return nil 62 | } 63 | } 64 | 65 | @MainActor 66 | func topMostViewController() -> UIViewController? { 67 | UIApplication.shared.topMostViewController 68 | } 69 | 70 | @MainActor 71 | func presentOnTop(_ vc: UIViewController) { 72 | guard let top = topMostViewController() else { 73 | print("Could not find the top view controller") 74 | return 75 | } 76 | top.present(vc, animated: true) 77 | } 78 | #endif 79 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/Shared/SwiftUIIntegration.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIIntegration.swift 3 | // 4 | // Created by Miguel de Icaza on 11/14/25. 5 | // 6 | // 7 | // StoreViewHelpers.swift 8 | // GodotApplePlugins 9 | // 10 | // Created by Miguel de Icaza on 11/21/25. 11 | // 12 | 13 | @preconcurrency import SwiftGodotRuntime 14 | import SwiftUI 15 | #if canImport(UIKit) 16 | import UIKit 17 | #else 18 | import AppKit 19 | #endif 20 | 21 | #if canImport(UIKIt) 22 | @MainActor 23 | func presentSwiftUIOverlayFromTopMost(_ view: V) { 24 | guard let presenter = topMostViewController() else { 25 | return 26 | } 27 | 28 | let hosting = UIHostingController(rootView: view) 29 | hosting.modalPresentationStyle = .formSheet 30 | hosting.view.backgroundColor = .clear 31 | 32 | presenter.present(hosting, animated: true) 33 | } 34 | #else 35 | #endif 36 | 37 | // Helper to present a SwiftUI view from Godot 38 | @MainActor 39 | func presentView(_ view: V) { 40 | #if canImport(UIKit) 41 | let controller = UIHostingController(rootView: view) 42 | presentOnTop(controller) 43 | #else 44 | let controller = NSHostingController(rootView: view) 45 | presentOnTop(controller) 46 | #endif 47 | } 48 | 49 | // Helper to dismiss the top view controller/window 50 | @MainActor 51 | func dismissTopView() { 52 | #if canImport(UIKit) 53 | // Find the top view controller and dismiss it 54 | if let keyWindow = UIApplication.shared.connectedScenes 55 | .filter({ $0.activationState == .foregroundActive }) 56 | .compactMap({ $0 as? UIWindowScene }) 57 | .first?.windows 58 | .filter({ $0.isKeyWindow }).first, 59 | let rootViewController = keyWindow.rootViewController { 60 | 61 | var topController = rootViewController 62 | while let presentedViewController = topController.presentedViewController { 63 | topController = presentedViewController 64 | } 65 | topController.dismiss(animated: true) 66 | } 67 | #else 68 | // On macOS, we might need to close the window or sheet 69 | // Implementation depends on how presentOnTop works on macOS. 70 | // Assuming presentOnTop presents as a sheet or new window. 71 | // For now, let's try to find the key window and close its sheet if it has one. 72 | if let window = NSApplication.shared.keyWindow { 73 | if let sheet = window.attachedSheet { 74 | window.endSheet(sheet) 75 | } else { 76 | // If it was presented as a separate window, we might need to close it. 77 | // But presentOnTop usually presents as a modal/sheet. 78 | } 79 | } 80 | #endif 81 | } 82 | -------------------------------------------------------------------------------- /doc_classes/SubscriptionStoreView.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | A view for displaying subscription options. 5 | 6 | 7 | This class presents a standard StoreKit subscription view. It can be configured to show a specific subscription group or a list of specific products. The visual style of the controls is also customizable. 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Dismisses the currently presented view. 16 | 17 | 18 | 19 | 20 | 21 | Presents the subscription store view on top of the current view hierarchy. 22 | 23 | 24 | 25 | 26 | 27 | The visual style of the subscription controls. See [enum ControlStyle] for options. 28 | 29 | 30 | The identifier of the subscription group to display. If set, this takes precedence over [member product_i_ds]. 31 | 32 | 33 | A list of specific product identifiers to display. Ignored if [member group_id] is set. 34 | 35 | 36 | 37 | 38 | The system automatically chooses the most appropriate style. 39 | 40 | 41 | A picker style. 42 | 43 | 44 | A button-based style. 45 | 46 | 47 | A compact picker style. 48 | 49 | 50 | A prominent picker style. 51 | 52 | 53 | A paged picker style. 54 | 55 | 56 | A prominent paged picker style. 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/StoreKit/SubscriptionOffer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubscriptionOffer.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 12/14/25. 6 | // 7 | 8 | 9 | @preconcurrency import SwiftGodotRuntime 10 | import StoreKit 11 | import SwiftUI 12 | 13 | class StoreProductSubscriptionOffer: RefCounted, @unchecked Sendable { 14 | var offer: Product.SubscriptionOffer? 15 | convenience init(offer: Product.SubscriptionOffer) { 16 | self.init() 17 | self.offer = offer 18 | } 19 | 20 | public enum OfferType: Int, CaseIterable { 21 | case introductory 22 | case promotional 23 | case winBack 24 | case unknown 25 | } 26 | 27 | @Export var offerId: String { offer?.id ?? ""} 28 | @Export var type: OfferType { 29 | guard let t = offer?.type else { 30 | return .unknown 31 | } 32 | if t == .introductory { return .introductory } 33 | if t == .promotional { return .promotional } 34 | if #available(macOS 15.0, iOS 18.0, *) { 35 | if t == .winBack { return .winBack } 36 | } 37 | return .unknown 38 | } 39 | 40 | @Export var typeLocalized: String { 41 | return offer?.type.localizedDescription ?? "" 42 | } 43 | 44 | @Export var displayPrice: String { 45 | offer?.displayPrice ?? "0" 46 | } 47 | 48 | @Export var priceDecimal: String { 49 | offer?.price.description ?? "0" 50 | } 51 | 52 | @Export var paymentMode: StoreProductPaymentMode? { 53 | guard let offer else { return nil } 54 | return StoreProductPaymentMode(paymentMode: offer.paymentMode) 55 | } 56 | 57 | @Export var period: StoreProductSubscriptionPeriod? { 58 | guard let offer else { return nil } 59 | return StoreProductSubscriptionPeriod(offer.period) 60 | } 61 | } 62 | 63 | @Godot 64 | class StoreProductPaymentMode: RefCounted, @unchecked Sendable { 65 | var paymentMode: Product.SubscriptionOffer.PaymentMode? 66 | convenience init(paymentMode: Product.SubscriptionOffer.PaymentMode) { 67 | self.init() 68 | self.paymentMode = paymentMode 69 | } 70 | 71 | @Callable static func get_free_trial() -> StoreProductPaymentMode { 72 | return StoreProductPaymentMode(paymentMode: .freeTrial) 73 | } 74 | @Callable static func get_pay_as_you_go() -> StoreProductPaymentMode { 75 | return StoreProductPaymentMode(paymentMode: .payAsYouGo) 76 | } 77 | @Callable static func pay_up_front() -> StoreProductPaymentMode { 78 | return StoreProductPaymentMode(paymentMode: .payUpFront) 79 | } 80 | 81 | @Export var localized_description: String { paymentMode?.localizedDescription ?? "" } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/StoreKit/SubscriptionOfferView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubscriptionOfferView.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/21/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import StoreKit 10 | import SwiftUI 11 | 12 | @Godot 13 | class SubscriptionOfferView: RefCounted, @unchecked Sendable { 14 | @Export var title: String = "Redeeming Offer..." 15 | @Signal var success: SimpleSignal 16 | @Signal("message") var error: SignalWithArguments 17 | 18 | // TODO: should this instead raise signals instead of the callback here? 19 | @Callable 20 | func present(callback: Callable) { 21 | Task { @MainActor in 22 | // To present offer code redemption, we usually use .offerCodeRedemption(isPresented: ...) on a view. 23 | // Since we are presenting a new view, we can create a dummy view that immediately presents the offer code sheet. 24 | // Or better, use the `SKPaymentQueue.default().presentCodeRedemptionSheet()` which is the older API but still works and is simpler for "presenting" something. 25 | // But for StoreKit 2, we should use the SwiftUI modifier. 26 | 27 | let view = OfferCodeWrapperView(title: title, offer: self) 28 | let wrappedView = NavigationView { 29 | view 30 | .toolbar { 31 | ToolbarItem(placement: .cancellationAction) { 32 | Button("Close") { 33 | dismissTopView() 34 | } 35 | } 36 | } 37 | } 38 | presentView(wrappedView) 39 | } 40 | } 41 | 42 | @Callable 43 | func dismiss() { 44 | Task { @MainActor in 45 | dismissTopView() 46 | } 47 | } 48 | } 49 | 50 | struct OfferCodeWrapperView: View { 51 | @State private var isPresented = true 52 | let title: String 53 | let offer: SubscriptionOfferView 54 | 55 | var body: some View { 56 | if #available(iOS 15.0, macOS 15.0, *) { 57 | Text(title) 58 | .onAppear { 59 | isPresented = true 60 | } 61 | .offerCodeRedemption(isPresented: $isPresented) { result in 62 | switch result { 63 | case .success: 64 | _ = offer.success.emit() 65 | dismissTopView() 66 | case .failure(let error): 67 | _ = offer.error.emit("Offer code redemption failed: \(error.localizedDescription)") 68 | dismissTopView() 69 | } 70 | } 71 | } else { 72 | Text("Offer redemption is not available on this OS version") 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /doc_classes/ASAuthorizationAppleIDCredential.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents a credential that results from a successful Apple ID authentication. 5 | 6 | 7 | This class contains information about the user authenticated via Sign in with Apple. It includes the user identifier, full name, email, and authentication tokens. 8 | 9 | The [member identity_token] and [member authorization_code] properties are provided as [PackedByteArray]s, which can be sent to your backend server for verification. 10 | 11 | The [member real_user_status] property can help you determine if the user is likely a real person or a bot. 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | The user's email address. may be a proxy address if the user chose to hide their email. 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | An arbitrary string that your app provided to the request that generated this credential. 31 | 32 | 33 | An identifier associated with the authenticated user. This identifier is stable and unique to your team. 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /doc_classes/GameCenterManager.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Entry point for authenticating the Apple Game Center local player. 5 | 6 | 7 | Create a [code skip-lint]GameCenterManager[/code] when your game starts to present Apple's authentication UI and track whether the player is signed in, as described in [code skip-lint]GameCenterGuide.md[/code]. Once authenticated you can fetch the [member local_player] to access achievements, leaderboards, friends, and matchmaking helpers. See Apple's documentation for [url=https://developer.apple.com/documentation/gamekit/gklocalplayer]Apple's GKLocalPlayer reference[/url] to learn more about the underlying authentication flow. 8 | 9 | Authentication example from the guide: 10 | [codeblocks] 11 | [gdscript] 12 | var game_center: GameCenterManager 13 | 14 | func _ready() -> void: 15 | game_center = GameCenterManager.new() 16 | 17 | game_center.authentication_error.connect(func(error: String) -> void: 18 | print("Received error %s" % error) 19 | ) 20 | game_center.authentication_result.connect(func(status: bool) -> void: 21 | print("Authentication updated, status: %s" % status) 22 | ) 23 | [/gdscript] 24 | [/codeblocks] 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Triggers [code skip-lint]GKLocalPlayer.authenticateHandler[/code], presenting Apple's login sheet when needed. Emits [signal authentication_result] with the new status and [signal authentication_error] if GameKit reports a failure. 33 | 34 | 35 | 36 | 37 | 38 | Returns the wrapper for [code skip-lint]GKLocalPlayer.local[/code]. Use this after successful authentication to call methods such as [code]load_friends()[/code] or [code]fetch_items_for_identity_verification_signature()[/code]. 39 | 40 | 41 | 42 | 43 | 44 | 45 | Emitted whenever GameKit authentication fails. The argument is the localized error string provided by Apple. 46 | 47 | 48 | 49 | 50 | 51 | Emitted after [code skip-lint]authenticate()[/code] finishes. The boolean is [code]true[/code] when [code skip-lint]GKLocalPlayer.local.isAuthenticated[/code] reports success. 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/StoreKit/StoreProduct.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StoreProduct.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/21/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import StoreKit 10 | 11 | @Godot 12 | class StoreProduct: RefCounted, @unchecked Sendable { 13 | var product: Product? 14 | 15 | convenience init(_ product: Product) { 16 | self.init() 17 | self.product = product 18 | } 19 | 20 | @Export var productId: String { product?.id ?? "" } 21 | @Export var displayName: String { product?.displayName ?? "" } 22 | @Export var descriptionValue: String { product?.description ?? "" } 23 | @Export var price: Double { 24 | guard let product else { return 0.0 } 25 | return Double(truncating: product.price as NSNumber) 26 | } 27 | @Export var displayPrice: String { product?.displayPrice ?? "" } 28 | @Export var isFamilyShareable: Bool { product?.isFamilyShareable ?? false } 29 | 30 | // Helper to get the JSON representation if needed for more details 31 | @Export var jsonRepresentation: String { 32 | guard let product else { return "" } 33 | return "\(product)" 34 | } 35 | } 36 | 37 | @Godot 38 | class StoreProductPurchaseOption: RefCounted, @unchecked Sendable { 39 | var purchaseOption: Product.PurchaseOption? 40 | 41 | convenience init(_ purchaseOption: Product.PurchaseOption) { 42 | self.init() 43 | self.purchaseOption = purchaseOption 44 | } 45 | 46 | @Callable 47 | static func app_account_token(stringUuidToken: String) -> StoreProductPurchaseOption? { 48 | guard let token = UUID(uuidString: stringUuidToken) else { return nil } 49 | let purchaseOption = Product.PurchaseOption.appAccountToken(token) 50 | return StoreProductPurchaseOption(purchaseOption) 51 | } 52 | 53 | @Callable 54 | static func win_back_offer(offer: StoreProductSubscriptionOffer?) -> StoreProductPurchaseOption? { 55 | guard let skoffer = offer?.offer else { 56 | return nil 57 | } 58 | if #available(iOS 18.0, macOS 15.0, *) { 59 | let purchaseOption = Product.PurchaseOption.winBackOffer(skoffer) 60 | return StoreProductPurchaseOption(purchaseOption) 61 | } else { 62 | return nil 63 | } 64 | } 65 | 66 | @Callable 67 | static func quantity(value: Int) -> StoreProductPurchaseOption? { 68 | return StoreProductPurchaseOption(Product.PurchaseOption.quantity(value)) 69 | } 70 | 71 | @Callable 72 | static func introductory_offer_elligibility(jws: String) -> StoreProductPurchaseOption? { 73 | return StoreProductPurchaseOption(Product.PurchaseOption.introductoryOfferEligibility(compactJWS: jws)) 74 | } 75 | 76 | @Callable 77 | static func simulate_ask_to_buy_in_sandbox(enabled: Bool) -> StoreProductPurchaseOption? { 78 | return StoreProductPurchaseOption(Product.PurchaseOption.simulatesAskToBuyInSandbox(enabled)) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /doc_classes/GKMatchRequest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Configures how many players Game Center should recruit for a realtime match. 5 | 6 | 7 | Instantiate [code skip-lint]GKMatchRequest[/code], set the minimum/maximum player counts and invite message, then either create a [code skip-lint]GKMatchmakerViewController[/code] (for full UI control) or call [method GKMatchmakerViewController.request_match] as shown in [code skip-lint]GameCenterGuide.md[/code]. The optional [code skip-lint]MatchType[/code] argument lets you switch between peer-to-peer, hosted, or turn-based matchmaking. Apple's guide is at [url=https://developer.apple.com/documentation/gamekit/gkmatchrequest]Apple's GKMatchRequest reference[/url]. 8 | 9 | Configuration snippet from the guide: 10 | [codeblocks] 11 | [gdscript] 12 | var request := GKMatchRequest.new() 13 | request.max_players = 2 14 | request.min_players = 1 15 | request.invite_message = "Join me in a quest to fun" 16 | 17 | var max_supported := GKMatchRequest.max_players_allowed_for_match(GKMatchRequest.MatchType.peerToPeer) 18 | print("Peer-to-peer supports up to %d players" % max_supported) 19 | [/gdscript] 20 | [/codeblocks] 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Returns Apple's maximum supported player count for the supplied match type ([code]peer_to_peer[/code], [code skip-lint]hosted[/code], or [code]turn_based[/code]). 30 | 31 | 32 | 33 | 34 | 35 | Default number of players your UI suggests when presenting the matchmaking sheet. 36 | 37 | 38 | Optional text Apple shows when the player sends invitations (see the [code skip-lint]invite_message[/code] example in the guide). 39 | 40 | 41 | Maximum desired players in the match. 42 | 43 | 44 | Minimum player count required before the match can start. 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /doc_classes/GKPlayer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents any Game Center player (local or remote). 5 | 6 | 7 | [code skip-lint]GKPlayer[/code] is the shared base type for friends, opponents, and the local user. Use it to access Apple-provided identifiers plus helper methods such as [method load_photo], as referenced in the "Players" section of [code skip-lint]GameCenterGuide.md[/code]. Apple's reference lives at [url=https://developer.apple.com/documentation/gamekit/gkplayer]Apple's GKPlayer documentation[/url]. 8 | 9 | Loading a player photo (guide sample): 10 | [codeblocks] 11 | [gdscript] 12 | # Put the downloaded image inside a TextureRect named $texture_rect. 13 | local_player.load_photo(true, func(image: Image, error: Variant) -> void: 14 | if error == null: 15 | $texture_rect.texture = ImageTexture.create_from_image(image) 16 | else: 17 | print("Could not load photo %s" % error) 18 | ) 19 | [/gdscript] 20 | [/codeblocks] 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Downloads the player's avatar. Pass [code]true[/code] for a small image or [code]false[/code] for the normal size. The callback receives [code](Image image, Variant error)[/code] and exactly one argument is [code]null[/code], mirroring the Swift inline documentation. 31 | 32 | 33 | 34 | 35 | 36 | Returns the result of Apple's [code skip-lint]scopedIDsArePersistent()[/code] method, letting you detect whether scoped identifiers stay stable for the current device/account combination. 37 | 38 | 39 | 40 | 41 | 42 | Player-specified nickname. 43 | 44 | 45 | Localized display name suited for UI. 46 | 47 | 48 | Stable, per-game identifier used by Apple to uniquely address the player. 49 | 50 | 51 | True if the player allows Game Center invitations. 52 | 53 | 54 | Team-scoped identifier shared with other games from the same developer account. 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /doc_classes/AppleURL.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents an Apple Foundation.URL type 5 | 6 | 7 | When working with Apple APIs, some of those APIs either return URLs or expect URLs, in particular 8 | this type is being exposed because Apple can return a URL when accessing files outside of 9 | your application sandbox, and you must call the [method start_accessing_security_scoped_resource] to 10 | begin accessing the resource and when you are done, you must call 11 | [method stop_accessing_security_scoped_resource]. 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Returns the URL.absoluteString property 21 | 22 | 23 | 24 | 25 | 26 | Loads the contents of a file referenced by the URL and returns it as a PackedByteArray. 27 | 28 | 29 | 30 | 31 | 32 | Returns the URL contents, without percent-encoding. 33 | 34 | 35 | 36 | 37 | 38 | Returns the URL contents, with percent-encoding. 39 | 40 | 41 | 42 | 43 | 44 | Fetches the contents of a file URL and returns it as a string. 45 | 46 | 47 | 48 | 49 | 50 | 51 | Sets the content of the URL from a string that represents a file path. 52 | 53 | 54 | 55 | 56 | 57 | 58 | Sets the content of the URL from a string, it will return false if the URL can not be parsed. 59 | 60 | 61 | 62 | 63 | 64 | In an app that has adopted App Sandbox, makes the resource pointed to by a security-scoped URL available to the app. 65 | 66 | If this call succeeds, you can access the resource, and when you are done, you must call [method stop_accessing_security_scoped_resource]. 67 | 68 | 69 | 70 | 71 | 72 | In an app that adopts App Sandbox, revokes access to the resource pointed to by a security-scoped URL. 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release Addons 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | package: 8 | runs-on: 9 | - self-hosted 10 | - macos 11 | permissions: 12 | contents: write 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Import Code Signing Certificate 18 | if: env.MACOS_CERTIFICATE != '' 19 | env: 20 | MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} 21 | MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} 22 | KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} 23 | run: | 24 | # Create temporary keychain 25 | KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db 26 | security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 27 | security set-keychain-settings -lut 21600 $KEYCHAIN_PATH 28 | security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 29 | 30 | # Import certificate 31 | echo "$MACOS_CERTIFICATE" | base64 --decode > $RUNNER_TEMP/certificate.p12 32 | security import $RUNNER_TEMP/certificate.p12 -P "$MACOS_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH 33 | security list-keychain -d user -s $KEYCHAIN_PATH 34 | 35 | # Allow codesign to access keychain 36 | security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH 37 | 38 | - name: Build xcframework package 39 | run: make build 40 | 41 | - name: Package for Distribution 42 | run: make dist 43 | 44 | - name: Sign macOS frameworks 45 | if: env.MACOS_CERTIFICATE != '' 46 | env: 47 | MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} 48 | run: | 49 | # Find signing identity from keychain (first code signing certificate) 50 | SIGNING_IDENTITY=$(security find-identity -v -p codesigning | grep -o '"[^"]*"' | head -1 | tr -d '"') 51 | echo "Using signing identity: $SIGNING_IDENTITY" 52 | 53 | # Sign all macOS frameworks 54 | for fw in addons/*/bin/*.framework; do 55 | if [[ -d "$fw" ]]; then 56 | echo "Signing $fw" 57 | codesign --keychain $RUNNER_TEMP/app-signing.keychain-db --force --options runtime \ 58 | --sign "$SIGNING_IDENTITY" \ 59 | $fw 60 | echo "Verifying file $signfile" 61 | codesign --keychain $RUNNER_TEMP/app-signing.keychain-db --verify --verbose $fw 62 | fi 63 | done 64 | 65 | - name: Archive addons directory 66 | run: | 67 | PACKAGE_NAME="GodotApplePlugins-addons-${GITHUB_SHA}.zip" 68 | mkdir dist 69 | mv addons dist 70 | zip -r "$PACKAGE_NAME" dist 71 | echo "PACKAGE_NAME=$PACKAGE_NAME" >> "$GITHUB_ENV" 72 | 73 | - name: Publish release 74 | if: startsWith(github.ref, 'refs/heads/') 75 | uses: softprops/action-gh-release@v1 76 | with: 77 | tag_name: build-${{ github.sha }} 78 | name: Addons build for ${{ github.sha }} 79 | body: Automated build triggered by ${{ github.ref }} 80 | files: ${{ env.PACKAGE_NAME }} 81 | -------------------------------------------------------------------------------- /doc_classes/GKGameCenterViewController.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | This class is used to present the different Game Center dashboards and the way to use it is by calling one of the different static show_* methods in this class in a fire-and-forget mode. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Presents an achivement to the user, the id is the identifier of the achievement. 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | Shows a leaderboard with the players on the specified scope. Use one of the [enum GKLeaderboard.PlayerScope] values. 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | Shows a leaderboard with the players on the specified scope and the time period. The scope should match [enum GKLeaderboard.PlayerScope] and the time scope should match [enum GKLeaderboard.TimeScope]. 33 | 34 | 35 | 36 | 37 | 38 | 39 | Shows a specific leaderboard set. 40 | 41 | 42 | 43 | 44 | 45 | 46 | Shows the specified player's GameCenter profile. 47 | 48 | 49 | 50 | 51 | 52 | 53 | Used to present one of the different Game Center dashboards specified by the type parameter. 54 | 55 | 56 | 57 | 58 | 59 | The default screen. 60 | 61 | 62 | The leaderboard sets or leaderboards if there are no sets. 63 | 64 | 65 | The list of achievement. 66 | 67 | 68 | The local player 69 | 70 | 71 | The dashboard. 72 | 73 | 74 | The friends list. 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /doc_classes/ASAuthorizationController.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Manages authorization requests for Apple ID and other services. 5 | 6 | 7 | This class handles the presentation and management of authorization flows, such as Sign in with Apple. It interacts with the [ASAuthorizationAppleIDCredential] and [ASPasswordCredential] classes to return the result of an authorization request. 8 | 9 | You can use [method signin_with_scopes] to request specific scopes (e.g., full name and email) or [method signin] for a default sign-in flow. 10 | 11 | [codeblocks] 12 | [gdscript] 13 | var auth_controller = ASAuthorizationController.new() 14 | 15 | func _ready(): 16 | auth_controller.authorization_completed.connect(_on_authorization_completed) 17 | auth_controller.authorization_failed.connect(_on_authorization_failed) 18 | 19 | func _on_sign_in_button_pressed(): 20 | # Request full name and email 21 | auth_controller.signin_with_scopes(["full_name", "email"]) 22 | 23 | func _on_authorization_completed(credential): 24 | if credential is ASAuthorizationAppleIDCredential: 25 | print("User ID: ", credential.user) 26 | print("Email: ", credential.email) 27 | print("Full Name: ", credential.fullName) 28 | elif credential is ASPasswordCredential: 29 | print("User: ", credential.user) 30 | print("Password: ", credential.password) 31 | 32 | func _on_authorization_failed(error_message): 33 | print("Authorization failed: ", error_message) 34 | [/gdscript] 35 | [/codeblocks] 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Call this method to initiate the signin with Apple workflow with the default scopes ([code]email[/code], [code]full_name[/code}]), 44 | this will raise the signal [signal authorization_completed] upon 45 | success, or the [signal authorization_failed] on failure. 46 | 47 | 48 | 49 | 50 | 51 | 52 | Call this method to initiate the signin with Apple workflow with a specific set of scopes, currently they can be any of [code]email[/code] 53 | and [code]full_name[/code]. This will raise the event [signal authorization_completed] upon 54 | success, or the [signal authorization_failed] on failure. 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | Emitted when the authorization request completes successfully. 63 | [param credential] is usually an [ASAuthorizationAppleIDCredential] or [ASPasswordCredential]. It can be null if the credential type is unsupported. 64 | 65 | 66 | 67 | 68 | 69 | Emitted when the authorization request fails. 70 | [param error] contains the localized description of the error. 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /doc_classes/GKMatchmakerViewController.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Presents Apple's Game Center matchmaking UI. 5 | 6 | 7 | Use this helper to show the stock [code skip-lint]GKMatchmakerViewController[/code] workflow described in [code skip-lint]GameCenterGuide.md[/code]. You can either create and present the controller manually for full control or call [method request_match] for the convenience flow demonstrated in the guide. See [url=https://developer.apple.com/documentation/gamekit/gkmatchmakerviewcontroller]Apple's GKMatchmakerViewController reference[/url] for Apple's API documentation. 8 | 9 | Convenience usage from the guide: 10 | [codeblocks] 11 | [gdscript] 12 | var request := GKMatchRequest.new() 13 | request.max_players = 2 14 | request.min_players = 1 15 | request.invite_message = "Join me in a quest to fun" 16 | 17 | GKMatchmakerViewController.request_match(request, func(game_match: GKMatch, error: Variant) -> void: 18 | if error: 19 | print("Could not request a match %s" % error) 20 | else: 21 | print("Match ready") 22 | # Store game_match and connect to its signals as needed. 23 | ) 24 | [/gdscript] 25 | [/codeblocks] 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Builds a wrapper around Apple's view controller for the supplied [code skip-lint]GKMatchRequest[/code]. Configure the returned object and call [method present] to display it. 35 | 36 | 37 | 38 | 39 | 40 | Presents the previously created view controller, handling both UIKit and AppKit dialog flows. 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | Shows the matchmaking UI and invokes the callback with [code](GKMatch match, Variant error)[/code] where only one argument is non-[code]null[/code]. Errors are reported as strings such as "cancelled" when the user dismisses the sheet. 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | Emitted when the user cancels the matchmaking UI. The string is empty on UIKit and contains an informational message on macOS. 57 | 58 | 59 | 60 | 61 | 62 | Fired when GameKit returns players for a hosted-server scenario. The array contains [code skip-lint]GKPlayer[/code] instances. 63 | 64 | 65 | 66 | 67 | 68 | Triggered when a peer-to-peer match has been found and is ready to start. 69 | 70 | 71 | 72 | 73 | 74 | Emitted if Apple reports an error while matchmaking. The string contains [code]localizedDescription[/code]. 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/AuthenticationServices/ASAuthorizationAppleIDCredential.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ASAuthorizationAppleIDCredential.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 12/07/25. 6 | // 7 | 8 | import Foundation 9 | import AuthenticationServices 10 | import SwiftGodotRuntime 11 | 12 | @Godot 13 | class ASAuthorizationAppleIDCredential: RefCounted, @unchecked Sendable { 14 | var credential: AuthenticationServices.ASAuthorizationAppleIDCredential? 15 | 16 | convenience init(credential: AuthenticationServices.ASAuthorizationAppleIDCredential) { 17 | self.init() 18 | self.credential = credential 19 | } 20 | 21 | @Export 22 | var user: String { 23 | credential?.user ?? "" 24 | } 25 | 26 | @Export 27 | var state: String { 28 | credential?.state ?? "" 29 | } 30 | 31 | @Export 32 | var email: String { 33 | credential?.email ?? "" 34 | } 35 | 36 | @Export 37 | var fullName: VariantDictionary { 38 | credential?.fullName?.toGodotDictionary() ?? VariantDictionary() 39 | } 40 | 41 | @Export 42 | var identityToken: PackedByteArray { 43 | guard let data = credential?.identityToken else { return PackedByteArray() } 44 | let res = PackedByteArray(Array(data)) 45 | return res 46 | } 47 | 48 | @Export 49 | var authorizationCode: PackedByteArray { 50 | guard let data = credential?.authorizationCode else { return PackedByteArray() } 51 | let res = PackedByteArray() 52 | for byte in data { res.append(value: Int64(byte)) } 53 | return res 54 | } 55 | 56 | enum UserDetectionStatus: Int, CaseIterable { 57 | case unsupported = 0 58 | case unknown = 1 59 | case likelyReal = 2 60 | 61 | static func from(_ status: ASUserDetectionStatus) -> UserDetectionStatus { 62 | switch status { 63 | case .unsupported: return .unsupported 64 | case .unknown: return .unknown 65 | case .likelyReal: return .likelyReal 66 | @unknown default: return .unknown 67 | } 68 | } 69 | } 70 | 71 | enum UserAgeRange: Int, CaseIterable { 72 | case notknown = 0 73 | case child = 1 74 | case notChild = 2 75 | 76 | static func from(_ range: ASUserAgeRange) -> UserAgeRange { 77 | switch range { 78 | case .unknown: return .notknown 79 | case .child: return .child 80 | case .notChild: return .notChild 81 | @unknown default: return .notknown 82 | } 83 | } 84 | } 85 | 86 | @Export 87 | var realUserStatus: UserDetectionStatus { 88 | UserDetectionStatus.from(credential?.realUserStatus ?? .unsupported) 89 | } 90 | 91 | @Export 92 | var userAgeRange: UserAgeRange { 93 | UserAgeRange.from(credential?.userAgeRange ?? .unknown) 94 | } 95 | 96 | @Export 97 | var authorizedScopes: VariantArray { 98 | let array = VariantArray() 99 | guard let credential else { return array } 100 | 101 | for scope in credential.authorizedScopes { 102 | if scope == .email { 103 | array.append(Variant("email")) 104 | } else if scope == .fullName { 105 | if let name = credential.fullName { 106 | array.append(Variant(name.toGodotDictionary())) 107 | } 108 | } 109 | } 110 | return array 111 | } 112 | } 113 | 114 | extension PersonNameComponents { 115 | func toGodotDictionary() -> VariantDictionary { 116 | let dict = VariantDictionary() 117 | if let namePrefix { dict["name_prefix"] = Variant(namePrefix) } 118 | if let givenName { dict["given_name"] = Variant(givenName) } 119 | if let middleName { dict["middle_name"] = Variant(middleName) } 120 | if let familyName { dict["family_name"] = Variant(familyName) } 121 | if let nameSuffix { dict["name_suffix"] = Variant(nameSuffix) } 122 | if let nickname { dict["nickname"] = Variant(nickname) } 123 | return dict 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /test-apple-godot-api/control.gd: -------------------------------------------------------------------------------- 1 | extends Control 2 | 3 | var gameCenter: GameCenterManager 4 | var local: GKLocalPlayer 5 | var auth_controller = ASAuthorizationController 6 | 7 | func _ready() -> void: 8 | gameCenter = GameCenterManager.new() 9 | local = gameCenter.local_player 10 | print(Time.get_unix_time_from_system()) 11 | #gameCenter.load_leaderboards(["BOARD_1", "BOARD_2"]) 12 | print("ONREADY: game center, is %s" % gameCenter) 13 | print("ONREADY: local, is auth: %s" % local.is_authenticated) 14 | print("ONREADY: local, player ID: %s" % local.game_player_id) 15 | 16 | auth_controller = ASAuthorizationController.new() 17 | auth_controller.authorization_completed.connect(_on_authorization_completed) 18 | auth_controller.authorization_failed.connect(_on_authorization_failed) 19 | 20 | func _on_button_pressed() -> void: 21 | # Request full name and email 22 | auth_controller.signin_with_scopes(["full_name", "email"]) 23 | var d = GKMatch.new() 24 | 25 | 26 | func _on_authorization_completed(credential): 27 | if credential is ASAuthorizationAppleIDCredential: 28 | print("User ID: ", credential.user) 29 | print("Email: ", credential.email) 30 | print("Full Name: ", credential.fullName) 31 | elif credential is ASPasswordCredential: 32 | print("User: ", credential.user) 33 | print("Password: ", credential.password) 34 | 35 | func _on_authorization_failed(error_message): 36 | print("Authorization failed: ", error_message) 37 | 38 | func _xon_button_pressed() -> void: 39 | var player = gameCenter.local_player 40 | print("Got %s" % player) 41 | print("Fetching the other object: %s" % player.is_authenticated) 42 | var demo = GKLeaderboard.new() 43 | 44 | gameCenter.authentication_error.connect(func(error: String) -> void: 45 | $auth_result.text = error 46 | ) 47 | gameCenter.authentication_result.connect(func(status: bool) -> void: 48 | print("") 49 | if status: 50 | $auth_result.text = player.display_name 51 | $auth_state.text = "Authenticated" 52 | gameCenter.local_player.load_photo(true, func(image: Image, error: Variant)->void: 53 | if error == null: 54 | $texture_rect.texture = ImageTexture.create_from_image(image) 55 | else: 56 | print(error) 57 | ) 58 | 59 | GKLeaderboard.load_leaderboards(["MyLeaderboard"], func(leaderboards: Array [GKLeaderboard], error: Variant)->void: 60 | var score = 100 61 | var context = 0 62 | 63 | leaderboards[0].submit_score(score, context, local, func(error: Variant)->void: 64 | if error: 65 | print("Error submitting leadeboard %s" % error) 66 | ) 67 | ) 68 | 69 | $auth_state.text = "Not Authenticated" 70 | ) 71 | gameCenter.authenticate() 72 | 73 | func _on_button_requestmatch_pressed() -> void: 74 | if true: 75 | var req = GKMatchRequest.new() 76 | req.max_players = 2 77 | req.min_players = 1 78 | req.invite_message = "Join me in a quest to fun" 79 | GKMatchmakerViewController.request_match(req, func(gameMatch: GKMatch, error: Variant)->void: 80 | if error: 81 | print("Could nto request a match %s" % error) 82 | else: 83 | print("Got a match!") 84 | gameMatch.data_received.connect(func (data: PackedByteArray, fromPlayer: GKPlayer)->void: 85 | print("received data from Player") 86 | ) 87 | gameMatch.data_received_for_recipient_from_player.connect(func(data: PackedByteArray, forRecipient: GKPlayer, fromRemotePlayer: GKPlayer)->void: 88 | print("Received data from a player to another player") 89 | ) 90 | gameMatch.did_fail_with_error.connect(func(error: String)->void: 91 | print("match failed with %s" % error) 92 | ) 93 | gameMatch.should_reinvite_disconnected_player = (func(player: GKPlayer)->bool: 94 | # We always reinvite 95 | return true 96 | ) 97 | gameMatch.player_changed.connect(func(player: GKPlayer, connected: bool)->void: 98 | print("Status of player changed to %s" % connected) 99 | ) 100 | var array = "Hello".to_utf8_buffer() 101 | var first = local 102 | var second = local 103 | 104 | gameMatch.send_data_to_all_players(array, GKMatch.SendDataMode.reliable) 105 | 106 | gameMatch.send(array, [first, second], GKMatch.SendDataMode.reliable) 107 | ) 108 | print("Not authenticated, authenticate first") 109 | -------------------------------------------------------------------------------- /doc_classes/GKAchievementDescription.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Metadata for every achievement that is defined in App Store Connect. 5 | 6 | 7 | [code skip-lint]GKAchievementDescription[/code] exposes the localized text, group assignments, and icon that you configure for each achievement. Use [method load_achievement_descriptions] as shown in [code skip-lint]GameCenterGuide.md[/code] to list everything the player can unlock, even before they have reported progress. Apple's documentation lives at [url=https://developer.apple.com/documentation/gamekit/gkachievementdescription]Apple's GKAchievementDescription reference[/url]. 8 | 9 | Listing descriptions (guide sample): 10 | [codeblocks] 11 | [gdscript] 12 | GKAchievementDescription.load_achievement_descriptions(func(descriptions: Array[GKAchievementDescription], error: Variant) -> void: 13 | if error: 14 | print("Load achievement description error %s" % error) 15 | else: 16 | for description in descriptions: 17 | print("Achievement Description ID: %s" % description.identifier) 18 | print(" Unachieved: %s" % description.unachieved_description) 19 | print(" Achieved: %s" % description.achieved_description) 20 | ) 21 | [/gdscript] 22 | [/codeblocks] 23 | 24 | Loading an image sample (assuming [code]description[/code] is one of the loaded entries): 25 | [codeblocks] 26 | [gdscript] 27 | description.load_image(func(image: Image, error: Variant) -> void: 28 | if error == null: 29 | $texture_rect.texture = ImageTexture.create_from_image(image) 30 | else: 31 | print("Error loading achievement image %s" % error) 32 | ) 33 | [/gdscript] 34 | [/codeblocks] 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Loads the entire catalog of achievement descriptions and calls the callback with [code skip-lint]Array[GKAchievementDescription][/code] and a [code skip-lint]Variant[/code] error ([code]null[/code] on success, or a string from GameKit). 44 | 45 | 46 | 47 | 48 | 49 | 50 | Downloads the image for this description. The callback receives [code](Image image, Variant error)[/code] where exactly one argument is [code]null[/code], matching the helper described in the guide's "Load Achievement Description Image" section. 51 | 52 | 53 | 54 | 55 | 56 | Text shown after the player completes the achievement. 57 | 58 | 59 | The optional group identifier configured on App Store Connect. 60 | 61 | 62 | Unique identifier string for this description. 63 | 64 | 65 | Indicates whether the achievement is currently hidden from the user. 66 | 67 | 68 | True when the achievement can be earned more than once. 69 | 70 | 71 | The point value that Apple shows when this achievement is completed. 72 | 73 | 74 | Either a double with Apple's reported rarity or [code]null[/code] on platforms that do not expose this data. 75 | 76 | 77 | Display title, localized according to the player's language. 78 | 79 | 80 | Text shown before the player meets the completion criteria. 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKGameCenterViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GKGameCenterViewController.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 12/2/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import SwiftUI 10 | #if canImport(UIKit) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | import GameKit 16 | 17 | @Godot 18 | class GKGameCenterViewController: RefCounted, @unchecked Sendable { 19 | class Delegate: NSObject, GameKit.GKGameCenterControllerDelegate { 20 | func gameCenterViewControllerDidFinish(_ gameCenterViewController: GameKit.GKGameCenterViewController) { 21 | #if os(iOS) 22 | gameCenterViewController.dismiss(animated: true) 23 | #else 24 | dialogController?.dismiss(gameCenterViewController) 25 | 26 | #endif 27 | done() 28 | } 29 | 30 | #if os(macOS) 31 | var dialogController: GKDialogController? 32 | #endif 33 | var done: () -> () 34 | 35 | init(done: @escaping () -> ()) { 36 | self.done = done 37 | } 38 | } 39 | 40 | enum State: Int, CaseIterable { 41 | case defaultScreen 42 | case leaderboards 43 | case achievements 44 | case localPlayerProfile 45 | case dashboard 46 | case localPlayerFriendsList 47 | 48 | func toGameKit() -> GameKit.GKGameCenterViewControllerState { 49 | switch self { 50 | case .defaultScreen: 51 | return .default 52 | case .leaderboards: 53 | return .leaderboards 54 | case .achievements: 55 | return .achievements 56 | case .localPlayerProfile: 57 | return .localPlayerProfile 58 | case .dashboard: 59 | return .dashboard 60 | case .localPlayerFriendsList: 61 | return .localPlayerFriendsList 62 | } 63 | } 64 | } 65 | 66 | /// Returns a view controller for the specified type, which you can then call present on 67 | @Callable static func show_type(_ type: State) { 68 | MainActor.assumeIsolated { 69 | let vc = GameKit.GKGameCenterViewController(state: type.toGameKit()) 70 | show(vc) 71 | } 72 | } 73 | 74 | @Callable static func show_leaderboard(leaderboard: GKLeaderboard, scope: GKLeaderboard.PlayerScope) { 75 | MainActor.assumeIsolated { 76 | let vc = GameKit.GKGameCenterViewController(leaderboard: leaderboard.board, playerScope: scope.toGameKit()) 77 | show(vc) 78 | } 79 | } 80 | 81 | @Callable static func show_leaderboard_time_period(id: String, scope: GKLeaderboard.PlayerScope, timeScope: GKLeaderboard.TimeScope) { 82 | MainActor.assumeIsolated { 83 | let vc = GameKit.GKGameCenterViewController(leaderboardID: id, playerScope: scope.toGameKit(), timeScope: timeScope.toGameKit()) 84 | show(vc) 85 | } 86 | } 87 | 88 | @Callable static func show_leaderboardset(id: String) { 89 | if #available(iOS 18.0, macOS 15.0, *) { 90 | MainActor.assumeIsolated { 91 | let vc = GameKit.GKGameCenterViewController(leaderboardSetID: id) 92 | show(vc) 93 | } 94 | } 95 | } 96 | 97 | @Callable static func show_achievement(id: String) { 98 | MainActor.assumeIsolated { 99 | let vc = GameKit.GKGameCenterViewController(achievementID: id) 100 | show(vc) 101 | } 102 | } 103 | 104 | @Callable static func show_player(player: GKPlayer) { 105 | if #available(iOS 18.0, macOS 15.0, *) { 106 | MainActor.assumeIsolated { 107 | let vc = GameKit.GKGameCenterViewController(player: player.player) 108 | show(vc) 109 | } 110 | } 111 | } 112 | 113 | @MainActor 114 | static func show(_ controller: GameKit.GKGameCenterViewController) { 115 | var hold: Delegate? 116 | hold = Delegate { 117 | hold = nil 118 | } 119 | controller.gameCenterDelegate = hold 120 | present(controller: controller) { 121 | #if os(macOS) 122 | hold?.dialogController = $0 as? GKDialogController 123 | #endif 124 | } 125 | } 126 | 127 | @MainActor 128 | static func present(controller: GameKit.GKGameCenterViewController, track: @MainActor (AnyObject) -> ()) { 129 | #if os(iOS) 130 | presentOnTop(controller) 131 | #else 132 | let dialogController = GKDialogController.shared() 133 | dialogController.parentWindow = NSApplication.shared.mainWindow 134 | dialogController.present(controller) 135 | track(dialogController) 136 | #endif 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/AuthenticationServices/ASAuthorizationController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ASAuthorizationController.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 12/07/25. 6 | // 7 | 8 | import Foundation 9 | import AuthenticationServices 10 | import SwiftGodotRuntime 11 | #if canImport(UIKit) 12 | import UIKit 13 | #else 14 | import AppKit 15 | #endif 16 | 17 | @Godot 18 | class ASAuthorizationController: RefCounted, @unchecked Sendable { 19 | /// Can be either ASAuthorizationAppleIDCredential, ASPasswordCredential or nil for others 20 | @Signal("credential") 21 | var authorization_completed: SignalWithArguments 22 | 23 | @Signal("message") 24 | var authorization_failed: SignalWithArguments 25 | 26 | var controller: AuthenticationServices.ASAuthorizationController? 27 | var proxy: Proxy? 28 | 29 | // maybe need to make this conform to ASAuthorizationControllerPresentationContextProviding 30 | // but I should add support for the DisplayServer's UIViewController extraction 31 | class Proxy: NSObject, ASAuthorizationControllerDelegate { 32 | weak var base: ASAuthorizationController? 33 | 34 | init(_ base: ASAuthorizationController) { 35 | self.base = base 36 | } 37 | 38 | @MainActor 39 | func authorizationController(controller: AuthenticationServices.ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { 40 | guard let base else { return } 41 | 42 | if let appleIDCredential = authorization.credential as? AuthenticationServices.ASAuthorizationAppleIDCredential { 43 | let wrapped = ASAuthorizationAppleIDCredential(credential: appleIDCredential) 44 | base.authorization_completed.emit(wrapped) 45 | } else if let passwordCredential = authorization.credential as? AuthenticationServices.ASPasswordCredential { 46 | let wrapped = ASPasswordCredential(credential: passwordCredential) 47 | base.authorization_completed.emit(wrapped) 48 | } else { 49 | // Unknown credential type, might be the enterprise credential, but I dont think any games need that. 50 | base.authorization_completed.emit(nil) 51 | } 52 | } 53 | 54 | @MainActor 55 | func authorizationController(controller: AuthenticationServices.ASAuthorizationController, didCompleteWithError error: Error) { 56 | base?.authorization_failed.emit(error.localizedDescription) 57 | } 58 | } 59 | 60 | // The more specific version of it 61 | @Callable 62 | func signin_with_scopes(scopeStrings: VariantArray) { 63 | var requestedScopes: [ASAuthorization.Scope] = [] 64 | for vscope in scopeStrings { 65 | guard let scope = String(vscope) else { continue } 66 | if scope == "email" { 67 | requestedScopes.append(.email) 68 | } else if scope == "full_name" { 69 | requestedScopes.append(.fullName) 70 | } 71 | } 72 | 73 | MainActor.assumeIsolated { 74 | let provider = ASAuthorizationAppleIDProvider() 75 | let request = provider.createRequest() 76 | 77 | request.requestedScopes = requestedScopes 78 | 79 | let controller = AuthenticationServices.ASAuthorizationController(authorizationRequests: [request]) 80 | self.controller = controller 81 | 82 | let proxy = Proxy(self) 83 | self.proxy = proxy 84 | 85 | controller.delegate = proxy 86 | 87 | // Since most folks would use Godot, we might not need this 88 | // controller.presentationContextProvider = proxy 89 | 90 | controller.performRequests() 91 | } 92 | } 93 | 94 | // Just a general purpose easy-to-use version 95 | @Callable 96 | func signin() { 97 | MainActor.assumeIsolated { 98 | let appleIDProvider = ASAuthorizationAppleIDProvider() 99 | let request = appleIDProvider.createRequest() 100 | request.requestedScopes = [.fullName, .email] 101 | 102 | let controller = AuthenticationServices.ASAuthorizationController(authorizationRequests: [request]) 103 | self.controller = controller 104 | 105 | let proxy = Proxy(self) 106 | self.proxy = proxy 107 | 108 | controller.delegate = proxy 109 | 110 | // Since most folks would use Godot, we might not need this 111 | //controller.presentationContextProvider = proxy 112 | 113 | controller.performRequests() 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![banner](./doctools/GodotApplePlugins.webp) 2 | 3 | Godot Plugins for deep Apple platform integration, works on MacOS and iOS. 4 | 5 |

6 | 7 | API Documentation | Download: Godot Asset Library | Download: GitHub Releases 8 | 9 |

10 | 11 | You can get a ready-to-use binary from the "releases" tab, just drag the contents into 12 | your addons directory. You can start testing right away on a Mac project, and for iOS, 13 | export your iOS project and run. 14 | 15 | This add-on currently includes comprehensive support for: 16 | 17 | * GameCenter [GameCenter Integration Guide](Sources/GodotApplePlugins/GameCenter/GameCenterGuide.md) 18 | * StoreKit2 (https://migueldeicaza.github.io/GodotApplePlugins/class_storekitmanager.html) 19 | * Sign-in with Apple (AuthenticationServices) 20 | 21 | The release contains both binaries for MacOS as dynamic libraries and 22 | an iOS xcframework compiled with the "Mergeable Library" feature. 23 | This means that for Debug builds, your Godot game contains a dynamic 24 | library (about 10 megs at the time of this writing) that does not need 25 | to be copied on every build speeding your development, but you can 26 | switch to "Release Mode" and set "Create Merged Binary" to "Manual" 27 | and you will further reduce the size of your executable (about 1.7 28 | megs at the time of this writing). 29 | 30 | # API Design 31 | 32 | The API surfaced by this add-ons is to be as close to possible to the Apple APIs (classes, methods names, enumerations) and to avoid attempting to provide an abstraction over them - as these tend to have impedance mismatches. 33 | 34 | In place of Apple delegate's pattern, I use Godot's callbacks - and I surfaced properties and methods use snake-case instead of Apple's camelCase, but beyond that, the mapping should be almost identical. 35 | 36 | Both GameCenter and AuthenticationServices APIs use class names that are 1:1 mappings to Apple's APIs as they use 2-letter namespaces (GK, AS) and they are not likely to conflicth with your code. For the StoreKit API, I chose to change the names as these APIs use terms that are too general (Store, Product) and could clash with your own code. 37 | 38 | # Notes on the APIs 39 | 40 | ## AuthenticationServices 41 | 42 | Make sure that your iOS or Mac app have the `com.apple.developer.applesignin` entitlment. 43 | When I am debugging this myself on macOS, I resign the official 44 | Godot download with this entitlement (you must download a provisioning profile that 45 | contains the entitlement, or the APIs will fail). 46 | 47 | For very simple uses, you can use: 48 | 49 | ```gdscript 50 | var auth_controller = ASAuthorizationController.new() 51 | 52 | func _ready(): 53 | auth_controller.authorization_completed.connect(_on_authorization_completed) 54 | auth_controller.authorization_failed.connect(_on_authorization_failed) 55 | 56 | func _on_sign_in_button_pressed(): 57 | # Request full name and email 58 | auth_controller.signin_with_scopes(["full_name", "email"]) 59 | 60 | func _on_authorization_completed(credential): 61 | if credential is ASAuthorizationAppleIDCredential: 62 | print("User ID: ", credential.user) 63 | print("Email: ", credential.email) 64 | print("Full Name: ", credential.fullName) 65 | elif credential is ASPasswordCredential: 66 | print("User: ", credential.user) 67 | print("Password: ", credential.password) 68 | ``` 69 | 70 | For more advance users, you will find that the API replicates Apple's API, and 71 | it surfaces the various features that you expect from it. 72 | 73 | ### Configure 74 | 75 | For iOS, set at Project -> Export -> iOS -> `entitlements/additional`: 76 | 77 | ```xml 78 | com.apple.developer.applesignin 79 | 80 | Default 81 | 82 | ``` 83 | 84 | For macOS, set the same entitlements as above (eg. when running codesign): 85 | 86 | ```sh 87 | codesign --force --options=runtime --verbose --timestamp \ 88 | --entitlements entitlements.plist --sign "" \ 89 | "MyApp.app/Contents/MacOS/MyApp" 90 | ``` 91 | 92 | where `entitlements.plist` contains again: 93 | 94 | ```xml 95 | com.apple.developer.applesignin 96 | 97 | Default 98 | 99 | ``` 100 | 101 | # Size 102 | 103 | This addon adds 2.5 megabytes to your executable for release builds, but it is 104 | larger during development to speed up your development. 105 | 106 | Plain Godot Export, empty: 107 | 108 | ``` 109 | Debug: 104 MB 110 | Release: 93 MB 111 | ``` 112 | 113 | Godot Export, adding GodotApplePlugins with mergeable libraries: 114 | 115 | ``` 116 | Debug: 107 MB 117 | Release: 95 MB 118 | ``` 119 | 120 | If you manually disable mergeable libraries and build your own addon: 121 | 122 | ``` 123 | Debug: 114 MB 124 | Release: 105 MB 125 | ``` 126 | 127 | # Credits 128 | 129 | The "AuthenticationServices" code was derived from [Dragos Daian's/ 130 | Nirmal Ac's](https://github.com/appsinacup/godot-apple-login) binding and 131 | Xogot's own use. Dragos also provided extensive technical guidance on 132 | putting together this addon for distribution. Thank you! 133 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKLocalPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GKLocalPlayer.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/17/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import SwiftUI 10 | #if canImport(UIKit) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | 16 | import GameKit 17 | 18 | @Godot 19 | class GKLocalPlayer: GKPlayer, @unchecked Sendable { 20 | var local: GameKit.GKLocalPlayer 21 | 22 | required init(_ context: InitContext) { 23 | local = GameKit.GKLocalPlayer.local 24 | super.init(context) 25 | player = local 26 | } 27 | 28 | init() { 29 | local = GameKit.GKLocalPlayer.local 30 | super.init(player: GameKit.GKLocalPlayer.local) 31 | } 32 | 33 | @Export var isAuthenticated: Bool { local.isAuthenticated } 34 | @Export var isUnderage: Bool { local.isUnderage } 35 | @Export var isMultiplayerGamingRestricted: Bool { local.isMultiplayerGamingRestricted } 36 | @Export var isPersonalizedCommunicationRestricted: Bool { local.isPersonalizedCommunicationRestricted } 37 | 38 | func friendDispatch(_ callback: Callable, _ friends: [GameKit.GKPlayer]?, _ error: (any Error)?) { 39 | let array = TypedArray() 40 | 41 | if let friends { 42 | for friend in friends { 43 | let gkplayer = GKPlayer(player: friend) 44 | array.append(gkplayer) 45 | } 46 | } 47 | 48 | _ = callback.call(Variant(array), mapError(error)) 49 | } 50 | 51 | /// Loads the friends, the callback receives two arguments an `Array[GKPlayer]` and Variant 52 | /// if the variant value is not nil, it contains a string with the error message 53 | @Callable func load_friends(callback: Callable) { 54 | local.loadFriends { friends, error in 55 | self.friendDispatch(callback, friends, error) 56 | } 57 | } 58 | 59 | /// Loads the challengeable friends, the callback receives two arguments an array of GKPlayers and a String error 60 | /// either one can be null 61 | @Callable func load_challengeable_friends(callback: Callable) { 62 | local.loadChallengableFriends { friends, error in 63 | self.friendDispatch(callback, friends, error) 64 | } 65 | } 66 | 67 | /// Loads the recent friends, the callback receives two arguments an array of GKPlayers and a String error 68 | /// either one can be null 69 | @Callable func load_recent_friends(callback: Callable) { 70 | local.loadRecentPlayers { friends, error in 71 | self.friendDispatch(callback, friends, error) 72 | } 73 | } 74 | 75 | /// You get two return values back a dictionary containing the result values and an error. 76 | /// 77 | /// If the error is not nil: 78 | /// - "url": The URL for the public encryption key. 79 | /// - "data": PackedByteArray containing verification signature that GameKit generates, or nil 80 | /// - "salt": PackedByteArray containing a random NSString that GameKit uses to compute the hash and randomize it. 81 | /// - "timestamp": Int with signature’s creation date and time timestamp 82 | /// 83 | @Callable 84 | func fetch_items_for_identity_verification_signature(callback: Callable) { 85 | local.fetchItems { url, data, salt, timestamp, error in 86 | let result = VariantDictionary(); 87 | 88 | if error == nil { 89 | let encodeData = data?.toPackedByteArray() 90 | let encodeSalt = salt?.toPackedByteArray() 91 | 92 | result["url"] = (Variant(url?.description ?? "")) 93 | result["data"] = encodeData != nil ? Variant(encodeData) : nil 94 | result["salt"] = encodeSalt != nil ? Variant(encodeSalt) : nil 95 | result["timestamp"] = Variant(timestamp) 96 | } 97 | _ = callback.call(Variant(result), mapError(error)) 98 | } 99 | } 100 | 101 | @Callable 102 | func save_game_data(data: PackedByteArray, withName: String, callback: Callable) { 103 | guard let converted = data.asData() else { 104 | _ = callback.call(nil, Variant(String("Could not convert the packed array to Data)"))) 105 | return 106 | } 107 | local.saveGameData(converted, withName: withName) { savedGame, error in 108 | var savedV: Variant? = nil 109 | if let savedGame = savedGame { 110 | savedV = Variant(GKSavedGame(saved: savedGame)) 111 | } 112 | _ = callback.call(savedV, mapError(error)) 113 | } 114 | } 115 | 116 | @Callable 117 | func fetch_saved_games(callback: Callable) { 118 | local.fetchSavedGames { savedGames, error in 119 | let ret = TypedArray() 120 | if let savedGames = savedGames { 121 | for sg in savedGames { 122 | ret.append(GKSavedGame(saved: sg)) 123 | } 124 | } 125 | _ = callback.call(Variant(ret), mapError(error)) 126 | } 127 | } 128 | 129 | @Callable 130 | func delete_saved_games(named: String, callback: Callable) { 131 | local.deleteSavedGames(withName: named) { error in 132 | _ = callback.call(mapError(nil)) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /doc_classes/GKAchievement.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents an Apple Game Center achievement and provides helper methods to manage progress. 5 | 6 | 7 | Use [code skip-lint]GKAchievement[/code] to mirror the player's progress that you configured on App Store Connect. The static helpers expose the same workflows mentioned in the Achievements section of [code skip-lint]GameCenterGuide.md[/code]: listing the player's reported achievements, resetting them during testing, and sending new completion percentages. Apple's API reference is available at [url=https://developer.apple.com/documentation/gamekit/gkachievement]Apple's GKAchievement reference[/url]. 8 | 9 | List all achievements (straight from the guide): 10 | [codeblocks] 11 | [gdscript] 12 | GKAchievement.load_achievements(func(achievements: Array[GKAchievement], error: Variant) -> void: 13 | if error: 14 | print("Load achievement error %s" % error) 15 | else: 16 | for achievement in achievements: 17 | print("Achievement: %s" % achievement.identifier) 18 | ) 19 | [/gdscript] 20 | [/codeblocks] 21 | 22 | Report progress and reset utilities: 23 | [codeblocks] 24 | [gdscript] 25 | var id := "a001" 26 | var percentage := 100.0 27 | 28 | GKAchievement.load_achievements(func(achievements: Array[GKAchievement], error: Variant) -> void: 29 | if error: 30 | print("Load achievement error %s" % error) 31 | return 32 | 33 | for achievement in achievements: 34 | if achievement.identifier == id and not achievement.is_completed: 35 | achievement.percent_complete = percentage 36 | achievement.shows_completion_banner = true 37 | GKAchievement.report_achivement([achievement], func(submit_error: Variant) -> void: 38 | if submit_error: 39 | print("Error submitting achievement %s" % submit_error) 40 | else: 41 | print("Success!") 42 | ) 43 | ) 44 | 45 | GKAchievement.reset_achivements(func(error: Variant) -> void: 46 | if error: 47 | print("Error resetting achievements %s" % error) 48 | else: 49 | print("Reset complete") 50 | ) 51 | [/gdscript] 52 | [/codeblocks] 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | Loads the achievements that the local player has already reported. The callback is invoked with [code skip-lint]Array[GKAchievement][/code] and a [code skip-lint]Variant[/code] error argument ([code]null[/code] on success, or a localized error string). 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Submits the new state for the provided achievements. The callback receives a single [code skip-lint]Variant[/code] that is [code]null[/code] on success or contains an error string reported by GameKit. This is the method used in the "Report Progress" snippet inside [code skip-lint]GameCenterGuide.md[/code]. 70 | 71 | 72 | 73 | 74 | 75 | 76 | Asks GameKit to clear every achievement for the local player. The callback receives [code]null[/code] on success or a [code skip-lint]Variant[/code] with the error description, matching Apple's [code]resetAchievements[/code] API. 77 | 78 | 79 | 80 | 81 | 82 | The achievement identifier configured on App Store Connect. 83 | 84 | 85 | Read-only flag that mirrors GameKit's [code]isCompleted[/code] property. 86 | 87 | 88 | 89 | 90 | The player's reported completion percentage (0-100). Update this and then call [method report_achivement] to submit it. 91 | 92 | 93 | The [code skip-lint]GKPlayer[/code] owner of this achievement, if GameKit was able to resolve it. 94 | 95 | 96 | Matches Apple's [code]showsCompletionBanner[/code] flag. Set it to true when you want the system to display the stock achievement toast when the progress hits 100%. 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/UI/AppleFilePicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleFilePicker.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 12/18/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import Foundation 10 | #if canImport(UIKit) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | import UniformTypeIdentifiers 16 | 17 | @Godot 18 | public class AppleFilePicker: RefCounted, @unchecked Sendable { 19 | // AppleURL, path 20 | @Signal("url", "path") var file_selected: SignalWithArguments 21 | 22 | // [AppleURL], [path] 23 | @Signal("urls", "paths") var files_selected: SignalWithArguments, PackedStringArray> 24 | 25 | @Signal var canceled: SimpleSignal 26 | 27 | // Maintain a strong reference to the delegate so it isn't deallocated 28 | private var delegate: AnyObject? 29 | 30 | #if os(iOS) 31 | class PickerDelegate: NSObject, UIDocumentPickerDelegate { 32 | weak var parent: AppleFilePicker? 33 | let allowMultiple: Bool 34 | 35 | init(_ parent: AppleFilePicker, allowMultiple: Bool) { 36 | self.parent = parent 37 | self.allowMultiple = allowMultiple 38 | } 39 | 40 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { 41 | defer { parent?.delegate = nil } 42 | guard !urls.isEmpty else { 43 | parent?.canceled.emit() 44 | return 45 | } 46 | 47 | if allowMultiple { 48 | let appleUrls = TypedArray() 49 | let paths = PackedStringArray() 50 | 51 | for url in urls { 52 | let appleURL = AppleURL() 53 | appleURL.url = url 54 | appleUrls.append(appleURL) 55 | paths.append(value: url.path) 56 | } 57 | parent?.files_selected.emit(appleUrls, paths) 58 | } else { 59 | if let url = urls.first { 60 | let appleURL = AppleURL() 61 | appleURL.url = url 62 | parent?.file_selected.emit(appleURL, url.path) 63 | } 64 | } 65 | } 66 | 67 | func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { 68 | parent?.delegate = nil 69 | parent?.canceled.emit() 70 | } 71 | } 72 | #endif 73 | 74 | @Callable 75 | func pick_document(allowedTypes: [String], allowMultiple: Bool = false) { 76 | // Convert string extensions/types to UTTypes 77 | var utTypes: [UTType] = [] 78 | for ext in allowedTypes { 79 | if let type = UTType(filenameExtension: ext) { 80 | utTypes.append(type) 81 | } else if let type = UTType(ext) { 82 | utTypes.append(type) 83 | } 84 | } 85 | 86 | // Default to content if no valid types provided, or maybe just public.item 87 | if utTypes.isEmpty { 88 | utTypes.append(.content) 89 | } 90 | 91 | DispatchQueue.main.async { [weak self] in 92 | guard let self = self else { return } 93 | self.showPicker(types: utTypes, allowMultiple: allowMultiple) 94 | } 95 | } 96 | 97 | @MainActor 98 | private func showPicker(types: [UTType], allowMultiple: Bool) { 99 | #if os(iOS) 100 | let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: false) 101 | let delegate = PickerDelegate(self, allowMultiple: allowMultiple) 102 | self.delegate = delegate 103 | picker.delegate = delegate 104 | picker.allowsMultipleSelection = allowMultiple 105 | 106 | presentOnTop(picker) 107 | 108 | #elseif os(macOS) 109 | let panel = NSOpenPanel() 110 | panel.allowedContentTypes = types 111 | panel.allowsMultipleSelection = allowMultiple 112 | panel.canChooseDirectories = false 113 | panel.canChooseFiles = true 114 | 115 | let handler: (NSApplication.ModalResponse) -> Void = { [weak self] response in 116 | guard let self = self else { return } 117 | if response == .OK { 118 | if allowMultiple { 119 | let appleUrls = TypedArray() 120 | let paths = PackedStringArray() 121 | 122 | for url in panel.urls { 123 | let appleURL = AppleURL() 124 | appleURL.url = url 125 | appleUrls.append(appleURL) 126 | paths.append(url.path) 127 | } 128 | self.files_selected.emit(appleUrls, paths) 129 | } else if let url = panel.url { 130 | let appleURL = AppleURL() 131 | appleURL.url = url 132 | self.file_selected.emit(appleURL, url.path) 133 | } 134 | } else { 135 | self.canceled.emit() 136 | } 137 | } 138 | 139 | if let window = NSApplication.shared.keyWindow ?? NSApplication.shared.mainWindow { 140 | panel.beginSheetModal(for: window, completionHandler: handler) 141 | } else { 142 | panel.begin(completionHandler: handler) 143 | } 144 | #endif 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKMatch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GKMatch.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/18/25. 6 | // 7 | 8 | 9 | @preconcurrency import SwiftGodotRuntime 10 | import SwiftUI 11 | #if canImport(UIKit) 12 | import UIKit 13 | #else 14 | import AppKit 15 | #endif 16 | import GameKit 17 | 18 | @Godot 19 | class GKMatch: RefCounted, @unchecked Sendable { 20 | var gkmatch = GameKit.GKMatch() 21 | var delegate: Proxy? 22 | 23 | enum SendDataMode: Int, CaseIterable { 24 | case reliable 25 | case unreliable 26 | func toGameKit() -> GameKit.GKMatch.SendDataMode { 27 | switch self { 28 | case .reliable: return .reliable 29 | case .unreliable: return .unreliable 30 | } 31 | } 32 | } 33 | 34 | convenience init(match: GameKit.GKMatch) { 35 | self.init() 36 | self.gkmatch = match 37 | 38 | // This is just so the proxy is not deallocated right away 39 | self.delegate = Proxy(self) 40 | gkmatch.delegate = self.delegate 41 | } 42 | 43 | class Proxy: NSObject, GKMatchDelegate { 44 | weak var base: GKMatch? 45 | 46 | init(_ base: GKMatch) { 47 | self.base = base 48 | } 49 | 50 | func match( 51 | _ match: GameKit.GKMatch, 52 | didReceive: Data, 53 | fromRemotePlayer: GameKit.GKPlayer 54 | ) { 55 | base?.data_received.emit(didReceive.toPackedByteArray(), GKPlayer(player: fromRemotePlayer)) 56 | } 57 | 58 | func match( 59 | _ match: GameKit.GKMatch, 60 | didReceive: Data, 61 | forRecipient: GameKit.GKPlayer, 62 | fromRemotePlayer: GameKit.GKPlayer 63 | ) { 64 | base?.data_received_for_recipient_from_player.emit( 65 | didReceive.toPackedByteArray(), 66 | GKPlayer(player: forRecipient), 67 | GKPlayer(player: fromRemotePlayer) 68 | ) 69 | } 70 | 71 | func match( 72 | _ match: GameKit.GKMatch, 73 | player: GameKit.GKPlayer, 74 | didChange: GKPlayerConnectionState 75 | ) { 76 | base?.player_changed.emit( 77 | GKPlayer(player: player), 78 | didChange == .connected 79 | ) 80 | } 81 | 82 | func match( 83 | _ match: GameKit.GKMatch, 84 | didFailWithError: (any Error)? 85 | ) { 86 | let res: String 87 | if let didFailWithError { 88 | res = didFailWithError.localizedDescription 89 | } else { 90 | res = "Generic error" 91 | } 92 | base?.did_fail_with_error.emit(res) 93 | } 94 | 95 | func match( 96 | _ match: GameKit.GKMatch, 97 | shouldReinviteDisconnectedPlayer: GameKit.GKPlayer 98 | ) -> Bool { 99 | guard let base, let cb = base.should_reinvite_disconnected_player else { 100 | return false 101 | } 102 | let retV = cb.call(Variant(GKPlayer(player: shouldReinviteDisconnectedPlayer))) 103 | if let ret = Bool(retV) { 104 | return ret 105 | } 106 | return false 107 | } 108 | } 109 | 110 | @Signal("data", "player") var data_received: SignalWithArguments 111 | @Signal("data", "recipient", "from_remote_player") var data_received_for_recipient_from_player: SignalWithArguments 112 | 113 | // The boolean indicates if it is connected (true) or disconncted(false 114 | @Signal("player", "connected") var player_changed: SignalWithArguments 115 | @Signal("message") var did_fail_with_error: SignalWithArguments 116 | 117 | // Connect to a function that accepts a GKPlayer and returns a boolean 118 | @Export var should_reinvite_disconnected_player: Callable? 119 | 120 | @Export var expected_player_count: Int { gkmatch.expectedPlayerCount } 121 | @Export var players: VariantArray { 122 | let result = VariantArray() 123 | for player in gkmatch.players { 124 | result.append(Variant(GKPlayer(player: player))) 125 | } 126 | return result 127 | } 128 | 129 | // TODO: these Godot errors could be better, or perhaps we should return a string? But I do not like 130 | // the idea of returning an empty string to say "ok" 131 | @Callable 132 | func send(data: PackedByteArray, toPlayers: VariantArray, dataMode: SendDataMode) -> GodotError { 133 | guard let sdata = data.asData() else { 134 | return .failed 135 | } 136 | var to: [GameKit.GKPlayer] = [] 137 | for po in toPlayers { 138 | guard let po, let player = po.asObject(GKPlayer.self) else { 139 | continue 140 | } 141 | to.append(player.player) 142 | } 143 | do { 144 | try gkmatch.send(sdata, to: to, dataMode: dataMode.toGameKit()) 145 | return .ok 146 | } catch { 147 | return .failed 148 | } 149 | } 150 | 151 | @Callable 152 | func send_data_to_all_players(data: PackedByteArray, dataMode: SendDataMode) -> GodotError { 153 | guard let sdata = data.asData() else { 154 | return .failed 155 | } 156 | do { 157 | try gkmatch.sendData(toAllPlayers: sdata, with: dataMode.toGameKit()) 158 | return .ok 159 | } catch { 160 | return .failed 161 | } 162 | } 163 | 164 | @Callable 165 | func disconnect() { 166 | gkmatch.disconnect() 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /fix_doc_enums.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # fix_doc_enums.sh - Post-process generated doc XML. 4 | # 5 | # 1. Convert enum parameter types from "Class.Enum" to "int" + enum attr. 6 | # 2. Add [code skip-lint] to snippets that trip Godot's doc linter. 7 | # 8 | 9 | set -euo pipefail 10 | 11 | DOC_DIR="${1:-doc_classes}" 12 | 13 | if [ ! -d "$DOC_DIR" ]; then 14 | echo "Error: Directory '$DOC_DIR' not found" >&2 15 | echo "Usage: $0 [doc_classes_directory]" >&2 16 | exit 1 17 | fi 18 | 19 | python3 - "$DOC_DIR" <<'PY' 20 | import re 21 | import sys 22 | from pathlib import Path 23 | 24 | doc_dir = Path(sys.argv[1]) 25 | 26 | ENUM_DOTTED_PATTERN = re.compile(r'type="([A-Z][A-Za-z0-9]*\.[A-Z][A-Za-z0-9]*)"') 27 | CODE_PATTERN = re.compile(r'\[code\]([A-Za-z0-9_.@\[\]\(\)]+)\[/code\]') 28 | ARRAY_PATTERN = re.compile(r'type="Array\[[^"]+\]"') 29 | ENUM_DECL_PATTERN = re.compile(r'enum="([A-Za-z0-9_.]+)"') 30 | CLASS_PATTERN = re.compile(r' tuple[str, bool]: 66 | changed = False 67 | 68 | def repl(match: re.Match) -> str: 69 | nonlocal changed 70 | changed = True 71 | return f'type="int" enum="{match.group(1)}"' 72 | 73 | text = ENUM_DOTTED_PATTERN.sub(repl, text) 74 | 75 | enum_names = {m.group(1).split(".")[-1] for m in ENUM_DECL_PATTERN.finditer(text)} 76 | alias_names = TYPE_ALIASES.get(class_name, set()) 77 | enum_names.update(alias_names) 78 | 79 | for enum_name in sorted(enum_names): 80 | pattern = re.compile(rf'(]*\btype="){re.escape(enum_name)}(")') 81 | text, count = pattern.subn(r'\1int\2', text) 82 | if count: 83 | changed = True 84 | 85 | text, count = ARRAY_PATTERN.subn('type="Array"', text) 86 | if count: 87 | changed = True 88 | 89 | return text, changed 90 | 91 | 92 | def add_skip_lint(text: str) -> tuple[str, bool]: 93 | changed = False 94 | 95 | def repl(match: re.Match) -> str: 96 | nonlocal changed 97 | token = match.group(1) 98 | normalized = token.lstrip("@") 99 | normalized = normalized.rstrip("()") 100 | normalized = normalized.replace("[", "").replace("]", "") 101 | if token in LOWER_SKIP or normalized in LOWER_SKIP or (normalized and normalized[0].isupper()): 102 | changed = True 103 | return f"[code skip-lint]{token}[/code]" 104 | return match.group(0) 105 | 106 | return CODE_PATTERN.sub(repl, text), changed 107 | 108 | 109 | def rename_signal_params(text: str, class_name: str) -> tuple[str, bool]: 110 | if class_name not in SIGNAL_PARAM_RENAMES: 111 | return text, False 112 | changed = False 113 | for signal_name, renames in SIGNAL_PARAM_RENAMES[class_name].items(): 114 | for index, new_name in renames.items(): 115 | pattern = re.compile( 116 | rf'(.*? tuple[str, bool]: 127 | changed = False 128 | 129 | def open_repl(match: re.Match) -> str: 130 | nonlocal changed 131 | changed = True 132 | indent = match.group(1) 133 | return f"{indent}[codeblocks]\n{indent}[gdscript]" 134 | 135 | def close_repl(match: re.Match) -> str: 136 | indent = match.group(1) 137 | return f"{indent}[/gdscript]\n{indent}[/codeblocks]" 138 | 139 | new_text = CODEBLOCK_OPEN_PATTERN.sub(open_repl, text) 140 | new_text = CODEBLOCK_CLOSE_PATTERN.sub(close_repl, new_text) 141 | if new_text != text: 142 | changed = True 143 | return new_text, changed 144 | 145 | 146 | def process_file(path: Path) -> bool: 147 | text = path.read_text(encoding="utf-8") 148 | class_match = CLASS_PATTERN.search(text) 149 | class_name = class_match.group(1) if class_match else "" 150 | 151 | text, enums_changed = replace_enum_types(text, class_name) 152 | text, skip_changed = add_skip_lint(text) 153 | text, rename_changed = rename_signal_params(text, class_name) 154 | text, tabs_changed = convert_codeblocks(text) 155 | 156 | if enums_changed or skip_changed or rename_changed or tabs_changed: 157 | path.write_text(text, encoding="utf-8") 158 | return True 159 | return False 160 | 161 | 162 | changed = 0 163 | for xml_file in sorted(doc_dir.glob("*.xml")): 164 | if process_file(xml_file): 165 | changed += 1 166 | print(f"Fixed: {xml_file.name}") 167 | 168 | if changed == 0: 169 | print("No files needed fixing") 170 | else: 171 | print(f"Fixed {changed} file(s)") 172 | PY 173 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKAchievement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppleAchievement.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/15/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import SwiftUI 10 | #if canImport(UIKit) 11 | import UIKit 12 | #else 13 | import AppKit 14 | #endif 15 | 16 | import GameKit 17 | 18 | @Godot 19 | class GKAchievement: RefCounted, @unchecked Sendable { 20 | var achievement: GameKit.GKAchievement = GameKit.GKAchievement() 21 | 22 | convenience init(identifier: String, player: GKPlayer?) { 23 | self.init() 24 | 25 | if let player { 26 | self.achievement = GameKit.GKAchievement(identifier: identifier, player: player.player) 27 | } else { 28 | self.achievement = GameKit.GKAchievement(identifier: identifier) 29 | } 30 | } 31 | 32 | convenience init(achievement: GameKit.GKAchievement) { 33 | self.init() 34 | self.achievement = achievement 35 | } 36 | 37 | @Export var identifier: String { 38 | get { achievement.identifier } 39 | set { achievement.identifier = newValue } 40 | } 41 | @Export var player: GKPlayer { GKPlayer(player: achievement.player) } 42 | @Export var percentComplete: Double { 43 | get { achievement.percentComplete } 44 | set { achievement.percentComplete = newValue } 45 | } 46 | @Export var isCompleted: Bool { achievement.isCompleted } 47 | @Export var showsCompletionBanner: Bool { 48 | get { achievement.showsCompletionBanner } 49 | set { achievement.showsCompletionBanner = newValue } 50 | } 51 | @Export var lastReportedDate: Double { 52 | achievement.lastReportedDate.timeIntervalSince1970 53 | } 54 | 55 | /// The callback is invoked with nil on success, or a string with a description of the error 56 | @Callable() 57 | static func report_achivement(achivements: VariantArray, callback: Callable) { 58 | var array: [GameKit.GKAchievement] = [] 59 | for va in achivements { 60 | guard let va else { continue } 61 | if let a = va.asObject(GKAchievement.self) { 62 | array.append(a.achievement) 63 | } 64 | } 65 | GameKit.GKAchievement.report(array) { error in 66 | _ = callback.call(mapError(error)) 67 | } 68 | } 69 | 70 | /// The callback is invoked with nil on success, or a string with a description of the error 71 | @Callable 72 | static func reset_achivements(callback: Callable) { 73 | GameKit.GKAchievement.resetAchievements { error in 74 | _ = callback.call(mapError(error)) 75 | } 76 | } 77 | 78 | /// Callback is invoked with two arguments an `Array[GKAchivement]` and an error argument 79 | /// on success the error i snil 80 | @Callable 81 | static func load_achievements(callback: Callable) { 82 | GameKit.GKAchievement.loadAchievements { achievements, error in 83 | let res = TypedArray() 84 | 85 | if let achievements { 86 | for ad in achievements { 87 | let ad = GKAchievement(achievement: ad) 88 | res.append(ad) 89 | } 90 | } 91 | _ = callback.call(Variant(res), mapError(error)) 92 | } 93 | } 94 | } 95 | 96 | @Godot 97 | class GKAchievementDescription: RefCounted, @unchecked Sendable { 98 | var achievementDescription: GameKit.GKAchievementDescription = GameKit.GKAchievementDescription() 99 | 100 | convenience init(_ ad: GameKit.GKAchievementDescription) { 101 | self.init() 102 | self.achievementDescription = ad 103 | } 104 | 105 | @Export var identifier: String { achievementDescription.identifier } 106 | @Export var title: String { achievementDescription.title } 107 | @Export var unachievedDescription: String { achievementDescription.unachievedDescription } 108 | @Export var achievedDescription: String { achievementDescription.achievedDescription } 109 | @Export var maximumPoints: Int { achievementDescription.maximumPoints } 110 | @Export var isHidden: Bool { achievementDescription.isHidden } 111 | @Export var isReplayable: Bool { achievementDescription.isReplayable } 112 | @Export var groupIdentifier: String { achievementDescription.groupIdentifier ?? "" } 113 | /// A double with the valur or nil 114 | @Export var rarityPercent: Variant? { 115 | if let rp = achievementDescription.rarityPercent { 116 | return Variant(rp) 117 | } else { 118 | return nil 119 | } 120 | } 121 | 122 | /// Callback is invoked with two arguments an Image witht he image and an error argument 123 | /// either one can be nil. 124 | @Callable 125 | func load_image(callback: Callable) { 126 | achievementDescription.loadImage { image, error in 127 | if let error { 128 | _ = callback.call(nil, mapError(error)) 129 | } else if let image, let godotImage = image.asGodotImage() { 130 | _ = callback.call(godotImage, nil) 131 | } else { 132 | _ = callback.call(nil, Variant("Could not load image")) 133 | } 134 | } 135 | } 136 | 137 | /// Callback is invoked with two arguments an array of GKAchivementDescriptions and an error argument 138 | /// either one can be nil. 139 | @Callable 140 | static func load_achievement_descriptions(callback: Callable) { 141 | GameKit.GKAchievementDescription.loadAchievementDescriptions { achievementDescriptions, error in 142 | let res = TypedArray() 143 | 144 | if let achievementDescriptions { 145 | for ad in achievementDescriptions { 146 | let ad = GKAchievementDescription(ad) 147 | res.append(ad) 148 | } 149 | } 150 | _ = callback.call(Variant(res), mapError(error)) 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: run xcframework check_swiftsyntax 2 | 3 | # Allow overriding common build knobs. 4 | CONFIG ?= Release 5 | DESTINATIONS ?= generic/platform=iOS platform=macOS,arch=arm64 platform=macOS,arch=x86_64 6 | DERIVED_DATA ?= $(CURDIR)/.xcodebuild 7 | WORKSPACE ?= .swiftpm/xcode/package.xcworkspace 8 | SCHEME ?= GodotApplePlugins 9 | FRAMEWORK_NAMES ?= GodotApplePlugins 10 | XCODEBUILD ?= xcodebuild 11 | 12 | run: 13 | @echo -e "Run make xcframework to produce the binary payloads for all platforms" 14 | 15 | build: 16 | set -e; \ 17 | swift build; \ 18 | for dest in $(DESTINATIONS); do \ 19 | suffix=`echo $$dest | sed 's,generic/platform=[a-zA-Z]*,,' | sed 's,platform=[a-zA-Z]*,,' | sed 's/,arch=//'`; \ 20 | platform_name=`echo $$dest | sed -n 's/.*platform=\([a-zA-Z0-9_]*\).*/\1/p'`; \ 21 | if [ -z "$$platform_name" ]; then \ 22 | platform_name="iOS"; \ 23 | fi; \ 24 | platform_lc=`echo $$platform_name | tr '[:upper:]' '[:lower:]'`; \ 25 | arch_name=`echo $$dest | sed -n 's/.*arch=\([a-zA-Z0-9_]*\).*/\1/p'`; \ 26 | if [ -z "$$arch_name" ] && [ "$$platform_lc" = "ios" ]; then \ 27 | arch_name="arm64"; \ 28 | fi; \ 29 | if [ -z "$$arch_name" ] && [ "$$platform_lc" = "macos" ]; then \ 30 | arch_name=`uname -m`; \ 31 | fi; \ 32 | echo HERE: $$suffix; \ 33 | for framework in $(FRAMEWORK_NAMES); do \ 34 | $(XCODEBUILD) \ 35 | -workspace '$(WORKSPACE)' \ 36 | -scheme $$framework \ 37 | -configuration '$(CONFIG)' \ 38 | -destination "$$dest" \ 39 | -derivedDataPath "$(DERIVED_DATA)$$suffix" \ 40 | build; \ 41 | if [ "$$platform_lc" = "ios" ] || [ "$$platform_lc" = "macos" ]; then \ 42 | $(CURDIR)/relink_without_swiftsyntax.sh \ 43 | --derived-data "$(DERIVED_DATA)$$suffix" \ 44 | --config "$(CONFIG)" \ 45 | --framework $$framework \ 46 | --platform $$platform_lc \ 47 | --arch $$arch_name; \ 48 | else \ 49 | echo "Skipping SwiftSyntax relink for $$framework on $$dest (unsupported platform)"; \ 50 | fi; \ 51 | done; \ 52 | done; \ 53 | 54 | check_swiftsyntax: 55 | set -e; \ 56 | pattern='SwiftSyntax|SwiftParser|SwiftDiagnostics|SwiftParserDiagnostics|SwiftBasicFormat|_SwiftSyntaxCShims'; \ 57 | failed=0; \ 58 | check_one() { \ 59 | sdk="$$1"; bin="$$2"; label="$$3"; \ 60 | if [ ! -f "$$bin" ]; then \ 61 | echo "SKIP: $$label (missing: $$bin)"; \ 62 | return 0; \ 63 | fi; \ 64 | if xcrun --sdk "$$sdk" nm -gU "$$bin" 2>/dev/null | grep -Eq "$$pattern"; then \ 65 | echo "FAIL: $$label still contains SwiftSyntax-related symbols"; \ 66 | failed=1; \ 67 | else \ 68 | echo "OK: $$label"; \ 69 | fi; \ 70 | }; \ 71 | for framework in $(FRAMEWORK_NAMES); do \ 72 | check_one iphoneos "$(DERIVED_DATA)/Build/Products/$(CONFIG)-iphoneos/PackageFrameworks/$$framework.framework/$$framework" "iOS/$$framework"; \ 73 | check_one macosx "$(DERIVED_DATA)arm64/Build/Products/$(CONFIG)/PackageFrameworks/$$framework.framework/$$framework" "macOS arm64/$$framework"; \ 74 | check_one macosx "$(DERIVED_DATA)x86_64/Build/Products/$(CONFIG)/PackageFrameworks/$$framework.framework/$$framework" "macOS x86_64/$$framework"; \ 75 | done; \ 76 | test "$$failed" -eq 0 77 | 78 | package: build dist 79 | 80 | dist: 81 | for framework in $(FRAMEWORK_NAMES); do \ 82 | rm -rf $(CURDIR)/addons/$$framework/bin/$$framework.xcframework; \ 83 | rm -rf $(CURDIR)/addons/$$framework/bin/$$framework*.framework; \ 84 | $(XCODEBUILD) -create-xcframework \ 85 | -framework $(DERIVED_DATA)/Build/Products/$(CONFIG)-iphoneos/PackageFrameworks/$$framework.framework \ 86 | -output $(CURDIR)/addons/$$framework/bin/$${framework}.xcframework; \ 87 | rsync -a $(DERIVED_DATA)x86_64/Build/Products/$(CONFIG)/PackageFrameworks/$${framework}.framework/ $(CURDIR)/addons/$$framework/bin/$${framework}_x64.framework; \ 88 | rsync -a $(DERIVED_DATA)arm64/Build/Products/$(CONFIG)/PackageFrameworks/$${framework}.framework/ $(CURDIR)/addons/$$framework/bin/$${framework}.framework; \ 89 | rsync -a doc_classes/ $(CURDIR)/addons/$$framework/bin/$${framework}_x64.framework/Resources/doc_classes/; \ 90 | rsync -a doc_classes/ $(CURDIR)/addons/$$framework/bin/$${framework}.framework/Resources/doc_classes/; \ 91 | done 92 | 93 | XCFRAMEWORK_GODOTAPPLEPLUGINS ?= $(CURDIR)/addons/GodotApplePlugins/bin/GodotApplePlugins.xcframework 94 | 95 | justgen: 96 | (cd test-apple-godot-api; ~/cvs/master-godot/editor/bin/godot.macos.editor.dev.arm64 --headless --path . --doctool .. --gdextension-docs) 97 | 98 | gendocs: justgen 99 | ./fix_doc_enums.sh 100 | $(MAKE) -C doctools html 101 | 102 | # 103 | # Quick hacks I use for rapid iteration 104 | # 105 | # My hack is that I build on Xcode for Mac and iPad first, then I 106 | # iterate by just rebuilding in one platform, and then running 107 | # "make o" here over and over, and my Godot project already has a 108 | # symlink here, so I can test quickly on desktop against the Mac 109 | # API. 110 | o: 111 | rm -rf '$(XCFRAMEWORK_GODOTAPPLEPLUGINS)'; \ 112 | rm -rf addons/GodotApplePlugins/bin/GodotApplePlugins.framework; \ 113 | $(XCODEBUILD) -create-xcframework \ 114 | -framework ~/DerivedData/GodotApplePlugins-*/Build/Products/Debug-iphoneos/PackageFrameworks/GodotApplePlugins.framework/ \ 115 | -output '$(XCFRAMEWORK_GODOTAPPLEPLUGINS)' 116 | cp -pr ~/DerivedData/GodotApplePlugins-*/Build/Products/Debug/PackageFrameworks/GodotApplePlugins.framework addons/GodotApplePlugins/bin/GodotApplePlugins.framework 117 | rsync -a doc_classes/ addons/GodotApplePlugins/bin/GodotApplePlugins.framework/Resources/doc_classes/ 118 | # 119 | # This I am using to test on the "Exported" project I placed 120 | # 121 | XCFRAMEWORK_EXPORT_PATH=test-apple-godot-api/TestAppleGodotApi/dylibs/addons/GodotApplePlugins/bin/GodotApplePlugins.xcframework 122 | make oo: 123 | rm -rf $(XCFRAMEWORK_EXPORT_PATH) 124 | $(XCODEBUILD) -create-xcframework \ 125 | -framework ~/DerivedData/GodotApplePlugins-*/Build/Products/Debug-iphoneos/PackageFrameworks/GodotApplePlugins.framework/ \ 126 | -framework ~/DerivedData/GodotApplePlugins-*/Build/Products/Debug/PackageFrameworks/GodotApplePlugins.framework/ \ 127 | -output '$(XCFRAMEWORK_EXPORT_PATH)' 128 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/StoreKit/SubscriptionStoreView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubscriptionStoreView.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/21/25. 6 | // 7 | 8 | @preconcurrency import SwiftGodotRuntime 9 | import StoreKit 10 | import SwiftUI 11 | 12 | @Godot 13 | class SubscriptionStoreView: RefCounted, @unchecked Sendable { 14 | @Export var groupID: String = "" 15 | @Export var productIDs: PackedStringArray = PackedStringArray() 16 | 17 | // Enum for SubscriptionStoreControlStyle 18 | enum ControlStyle: Int, CaseIterable { 19 | case automatic 20 | case picker 21 | case buttons 22 | case compactPicker 23 | case prominentPicker 24 | case pagedPicker 25 | case pagedProminentPicker 26 | } 27 | 28 | @Export(.enum) var controlStyle: ControlStyle = .automatic 29 | 30 | struct ShowSubscriptionStoreView: View { 31 | @Environment(\.dismiss) private var dismiss 32 | var groupID: String 33 | var productIDs: [String] 34 | 35 | var controlStyle: ControlStyle 36 | var body: some View { 37 | Group { 38 | if groupID != "" { 39 | StoreKit.SubscriptionStoreView(groupID: groupID) 40 | } else { 41 | StoreKit.SubscriptionStoreView(productIDs: productIDs) 42 | } 43 | } 44 | .toolbar { 45 | ToolbarItem(placement: .cancellationAction) { 46 | Button("Close") { 47 | dismiss() 48 | } 49 | } 50 | } 51 | } 52 | } 53 | 54 | @Callable 55 | func present() { 56 | MainActor.assumeIsolated { 57 | var ids: [String] = [] 58 | if productIDs.count > 0 { 59 | for id in productIDs { 60 | ids.append(id) 61 | } 62 | } 63 | 64 | let wrappedView = NavigationView { 65 | // Ugly, but not sure what to do other than AnyViewing itall. 66 | Group { 67 | switch controlStyle { 68 | case .automatic: 69 | if !groupID.isEmpty { 70 | StoreKit.SubscriptionStoreView(groupID: groupID) 71 | .subscriptionStoreControlStyle(.automatic) 72 | } else { 73 | StoreKit.SubscriptionStoreView(productIDs: ids) 74 | .subscriptionStoreControlStyle(.automatic) 75 | } 76 | case .picker: 77 | if !groupID.isEmpty { 78 | StoreKit.SubscriptionStoreView(groupID: groupID) 79 | .subscriptionStoreControlStyle(.automatic) 80 | } else { 81 | StoreKit.SubscriptionStoreView(productIDs: ids) 82 | .subscriptionStoreControlStyle(.automatic) 83 | } 84 | case .buttons: 85 | if !groupID.isEmpty { 86 | StoreKit.SubscriptionStoreView(groupID: groupID) 87 | .subscriptionStoreControlStyle(.automatic) 88 | } else { 89 | StoreKit.SubscriptionStoreView(productIDs: ids) 90 | .subscriptionStoreControlStyle(.automatic) 91 | } 92 | case .compactPicker: 93 | if !groupID.isEmpty { 94 | StoreKit.SubscriptionStoreView(groupID: groupID) 95 | .subscriptionStoreControlStyle(.automatic) 96 | } else { 97 | StoreKit.SubscriptionStoreView(productIDs: ids) 98 | .subscriptionStoreControlStyle(.automatic) 99 | } 100 | case .prominentPicker: 101 | if !groupID.isEmpty { 102 | StoreKit.SubscriptionStoreView(groupID: groupID) 103 | .subscriptionStoreControlStyle(.automatic) 104 | } else { 105 | StoreKit.SubscriptionStoreView(productIDs: ids) 106 | .subscriptionStoreControlStyle(.automatic) 107 | } 108 | case .pagedPicker: 109 | if !groupID.isEmpty { 110 | StoreKit.SubscriptionStoreView(groupID: groupID) 111 | .subscriptionStoreControlStyle(.automatic) 112 | } else { 113 | StoreKit.SubscriptionStoreView(productIDs: ids) 114 | .subscriptionStoreControlStyle(.automatic) 115 | } 116 | case .pagedProminentPicker: 117 | if !groupID.isEmpty { 118 | StoreKit.SubscriptionStoreView(groupID: groupID) 119 | .subscriptionStoreControlStyle(.automatic) 120 | } else { 121 | StoreKit.SubscriptionStoreView(productIDs: ids) 122 | .subscriptionStoreControlStyle(.automatic) 123 | } 124 | } 125 | } 126 | .toolbar { 127 | ToolbarItem(placement: .cancellationAction) { 128 | Button("Close") { 129 | dismissTopView() 130 | } 131 | } 132 | } 133 | } 134 | presentView(wrappedView) 135 | } 136 | } 137 | 138 | @Callable 139 | func dismiss() { 140 | Task { @MainActor in 141 | dismissTopView() 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /doc_classes/StoreKitManager.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Manages StoreKit interactions such as requesting products, purchasing, and restoring purchases. 5 | 6 | 7 | This class handles the core StoreKit functionality. It provides methods to fetch product information from the App Store, initiate purchases, 8 | restore previous purchases and be notified of the current status of the purchase upon startup. 9 | 10 | Once you instantiate this class, connect to the the [signal transaction_updated] signal to 11 | get notification related to the status of your purchases - these are delivered to notify 12 | your application of what products your user is entitled to at startup. If you are using 13 | Apple's PurchaseIntent, you will also want to connect to the [signal purchas_intent]. 14 | 15 | After you have set up your signals, you need to call [method start] when you are ready 16 | to receive those events. 17 | 18 | In addition, when you call the [method purchase] or [method purchase_with_options] you will 19 | want to setup a handler for the [signal purchase_completed] signal which is raised in 20 | response to different events during the purchasing. 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Initiates the purchase of a specific product, e.g. [code]purchase(product)[/code]. This will raise the 30 | [signal purchase_completed] signal, either to indicate that an error took place, or the status of the 31 | purchase. 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | Initiates the purchase of a specific product, e.g. [code]purchase(product)[/code], and allows you to provide additional purchase options. 40 | This will raise the 41 | [signal purchase_completed] signal, either to indicate that an error took place, or the status of the 42 | purchase. 43 | 44 | 45 | 46 | 47 | 48 | 49 | Requests product information for a list of product identifiers, e.g. [code]request_products(["com.example.product1", "com.example.product2"])[/code]. 50 | This method will raise the [signal products_request_completed] signal when the information is retrieved. 51 | 52 | 53 | 54 | 55 | 56 | Restores previously purchased non-consumable products and auto-renewable subscriptions. This will raise the 57 | [signal restore_completed] signal when the product purchased have been restored. 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | Emitted when a product request completes. 67 | [param products] is an Array of [StoreProduct]s (or nulls). 68 | [param status] indicates success or failure. 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | Emitted when a purchase completes. 77 | [param transaction] is the [StoreTransaction] on success. 78 | [param status] indicates the result (OK, cancelled, invalid product, etc.). 79 | [param error_message] contains error details if failed. 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | Emitted when the restore process completes. `arg1` is the [enum StoreKitStatus], and `arg2` is an error message if applicable. 92 | 93 | 94 | 95 | 96 | 97 | Emitted when a transaction is updated (e.g., a subscription renews or a purchase is approved externally). `arg1` is the updated [StoreTransaction]. 98 | 99 | 100 | 101 | 102 | 103 | The operation completed successfully. 104 | 105 | 106 | The product identifier is invalid. 107 | 108 | 109 | The operation was cancelled. 110 | 111 | 112 | The transaction could not be verified. 113 | 114 | 115 | The user cancelled the operation. 116 | 117 | 118 | The purchase is pending (e.g., waiting for parental approval). 119 | 120 | 121 | An unknown status occurred. 122 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /doc_classes/GKLocalPlayer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents the signed-in Game Center player and exposes local-only APIs. 5 | 6 | 7 | Use [code skip-lint]GKLocalPlayer[/code] to inspect authentication flags and to fetch the local player's friends as shown in the "Players" section of [code skip-lint]GameCenterGuide.md[/code]. Every asynchronous call mirrors Apple's API and invokes your [code skip-lint]Callable[/code] with the requested data plus an optional error [code skip-lint]Variant[/code]. Refer to Apple's documentation at [url=https://developer.apple.com/documentation/gamekit/gklocalplayer]Apple's GKLocalPlayer reference[/url] for platform-specific behavior. 8 | 9 | Sample from the guide that fetches the local player when your scene loads: 10 | [codeblocks] 11 | [gdscript] 12 | var game_center: GameCenterManager 13 | var local: GKLocalPlayer 14 | 15 | func _ready() -> void: 16 | game_center = GameCenterManager.new() 17 | local = game_center.local_player 18 | print("ONREADY: local, is auth: %s" % local.is_authenticated) 19 | print("ONREADY: local, player ID: %s" % local.game_player_id) 20 | [/gdscript] 21 | [/codeblocks] 22 | 23 | Friends-related helpers mirror the snippets in the guide (assuming you stored the player reference in [code]local[/code]): 24 | [codeblocks] 25 | [gdscript] 26 | # Loads the local player's friends list if access is granted. 27 | local.load_friends(func(friends: Array[GKPlayer], error: Variant) -> void: 28 | if error: 29 | print(error) 30 | else: 31 | for friend in friends: 32 | print(friend.display_name) 33 | ) 34 | 35 | # Loads players the local user can challenge. 36 | local.load_challengeable_friends(func(friends: Array[GKPlayer], error: Variant) -> void: 37 | if error: 38 | print(error) 39 | else: 40 | for friend in friends: 41 | print(friend.display_name) 42 | ) 43 | 44 | # Loads the friends or recent players list. 45 | local.load_recent_friends(func(friends: Array[GKPlayer], error: Variant) -> void: 46 | if error: 47 | print(error) 48 | else: 49 | for friend in friends: 50 | print(friend.display_name) 51 | ) 52 | [/gdscript] 53 | [/codeblocks] 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Calls Apple's [code]fetchItems[/code] helper for server-side authentication. The callback receives [code](Dictionary data, Variant error)[/code]. The dictionary contains the [code]url[/code], [code]data[/code], [code]salt[/code], and [code]timestamp[/code] keys described in the inline Swift documentation, letting your backend verify the player's identity. 70 | 71 | 72 | 73 | 74 | 75 | 76 | Use this API to retrieve the list of saved games, upon completion, this 77 | method invokes the provided callback with both an array of GKSavedGame objects 78 | and a variant error, if not nil it contains a string describing the problem. 79 | 80 | 81 | 82 | 83 | 84 | 85 | Loads players whom the local user can challenge. The callback receives [code](Array[GKPlayer] friends, Variant error)[/code] where either argument can be [code]null[/code]. 86 | 87 | 88 | 89 | 90 | 91 | 92 | Fetches the friends list. The callback receives [code](Array[GKPlayer] friends, Variant error)[/code]; a non-null error [code skip-lint]Variant[/code] holds the localized error string. 93 | 94 | 95 | 96 | 97 | 98 | 99 | Loads friends that recently played together with the local player. The callback signature matches [method load_challengeable_friends]. 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | Saves the packed byte array as the game data with the specified name, upon 109 | completion the callback is invoked with both a GKSavedObject parameter and 110 | a Variant parameter for the error. The GKSavedObject is not-nil on success, 111 | and on error, the second parameter is not-nil and contains the error message. 112 | 113 | 114 | 115 | 116 | 117 | Reflects [code skip-lint]GKLocalPlayer.local.isAuthenticated[/code]. 118 | 119 | 120 | True when multiplayer is disabled by parental controls. 121 | 122 | 123 | Indicates whether personalized communication is blocked for this account. 124 | 125 | 126 | Apple's [code]isUnderage[/code] flag for COPPA-compliant flows. 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /doc_classes/GKMatch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Represents an active real-time Game Center match. 5 | 6 | 7 | [code skip-lint]GKMatch[/code] wraps Apple's realtime networking object. After starting matchmaking with [code skip-lint]GKMatchmakerViewController[/code] (see the "Realtime Matchmaking" section of [code skip-lint]GameCenterGuide.md[/code]) you will receive a [code skip-lint]GKMatch[/code] instance and can send data, monitor players, and respond to disconnections via Godot signals. For platform specifics see [url=https://developer.apple.com/documentation/gamekit/gkmatch]Apple's GKMatch reference[/url]. 8 | 9 | Guide sample showing matchmaking and signal hookups: 10 | [codeblocks] 11 | [gdscript] 12 | var request := GKMatchRequest.new() 13 | request.max_players = 2 14 | request.min_players = 1 15 | request.invite_message = "Join me in a quest to fun" 16 | 17 | GKMatchmakerViewController.request_match(request, func(game_match: GKMatch, error: Variant) -> void: 18 | if error: 19 | print("Could not request a match %s" % error) 20 | return 21 | 22 | print("Got a match!") 23 | game_match.data_received.connect(func(data: PackedByteArray, from_player: GKPlayer) -> void: 24 | print("Received data from %s" % from_player.display_name) 25 | ) 26 | game_match.data_received_for_recipient_from_player.connect(func(data: PackedByteArray, for_recipient: GKPlayer, from_remote_player: GKPlayer) -> void: 27 | print("Forwarded data for %s from %s" % [for_recipient.display_name, from_remote_player.display_name]) 28 | ) 29 | game_match.did_fail_with_error.connect(func(match_error: String) -> void: 30 | print("Match failed with %s" % match_error) 31 | ) 32 | game_match.should_reinvite_disconnected_player = func(player: GKPlayer) -> bool: 33 | return true 34 | game_match.player_changed.connect(func(player: GKPlayer, connected: bool) -> void: 35 | print("Player %s changed to %s" % [player.display_name, connected]) 36 | ) 37 | ) 38 | [/gdscript] 39 | [/codeblocks] 40 | 41 | Broadcast helper from the guide (assuming you keep the reference as [code]game_match[/code]): 42 | [codeblocks] 43 | [gdscript] 44 | var data := "How do you do fellow kids".to_utf8_buffer() 45 | game_match.send_data_to_all_players(data, GKMatch.SendDataMode.reliable) 46 | [/gdscript] 47 | [/codeblocks] 48 | 49 | Send to a subset of players (still using the [code]game_match[/code] variable): 50 | [codeblocks] 51 | [gdscript] 52 | var payload := PackedByteArray([1, 2, 3]) 53 | var recipients := [first_player, second_player] 54 | game_match.send(payload, recipients, GKMatch.SendDataMode.reliable) 55 | [/gdscript] 56 | [/codeblocks] 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | Calls [code skip-lint]GKMatch.disconnect()[/code] to leave the match immediately. 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | Sends a payload to the specified [code skip-lint]Array[GKPlayer][/code]. Returns a [enum @GlobalScope.Error] value ([code skip-lint]OK[/code] on success, [code skip-lint]FAILED[/code] when the payload could not be converted or Apple reported an error). Choose a [enum SendDataMode] constant for [param dataMode]. 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Broadcasts the packed bytes to everyone in the match. Return value matches [method send]. Use the [enum SendDataMode] constants. 82 | 83 | 84 | 85 | 86 | 87 | The number of additional players that GameKit is recruiting before the match can begin. 88 | 89 | 90 | Array of [code skip-lint]GKPlayer[/code] instances that are currently connected. 91 | 92 | 93 | Optional [code skip-lint]Callable[/code] that receives a [code skip-lint]GKPlayer[/code] and returns [code]true[/code] if the player should be reinvited after disconnecting (see the sample in [code skip-lint]GameCenterGuide.md[/code]). 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | Emitted when another player sends data to the local device. Provides the raw bytes and the sender. 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | Emitted when the local player receives data that was addressed to a specific recipient. Arguments provide the payload, the intended recipient, and the sender. 110 | 111 | 112 | 113 | 114 | 115 | Fires when Apple reports a networking error. The [code skip-lint]String[/code] argument contains the localized description. 116 | 117 | 118 | 119 | 120 | 121 | 122 | Notifies you when a player connects or disconnects. The boolean is [code]true[/code] for connected players and [code]false[/code] otherwise. 123 | 124 | 125 | 126 | 127 | 128 | Uses GameKit's reliable data channel (ordered delivery with retries). 129 | 130 | 131 | Uses GameKit's unreliable channel, useful for latency-sensitive updates. 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /Sources/GodotApplePlugins/GameCenter/GKMatchMakerViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GKMatchMakerViewController.swift 3 | // GodotApplePlugins 4 | // 5 | // Created by Miguel de Icaza on 11/17/25. 6 | // 7 | @preconcurrency import SwiftGodotRuntime 8 | import SwiftUI 9 | #if canImport(UIKit) 10 | import UIKit 11 | #else 12 | import AppKit 13 | #endif 14 | import GameKit 15 | 16 | @Godot 17 | class GKMatchmakerViewController: RefCounted, @unchecked Sendable { 18 | class Proxy: NSObject, GameKit.GKMatchmakerViewControllerDelegate, GKLocalPlayerListener { 19 | func matchmakerViewControllerWasCancelled(_ viewController: GameKit.GKMatchmakerViewController) { 20 | guard let base else { return } 21 | MainActor.assumeIsolated { 22 | #if os(macOS) 23 | base.dialogController?.dismiss(viewController) 24 | #else 25 | viewController.dismiss(animated: true) 26 | #endif 27 | base.cancelled.emit("") 28 | } 29 | } 30 | 31 | func matchmakerViewController(_ viewController: GameKit.GKMatchmakerViewController, didFailWithError error: any Error) { 32 | GD.print("GKMVC: didFailWithError") 33 | base?.failed_with_error.emit(String(describing: error)) 34 | } 35 | 36 | func matchmakerViewController(_ viewController: GameKit.GKMatchmakerViewController, didFind match: GameKit.GKMatch) { 37 | base?.did_find_match.emit(GKMatch(match: match)) 38 | } 39 | 40 | func matchmakerViewController( 41 | _ viewController: GameKit.GKMatchmakerViewController, 42 | didFindHostedPlayers players: [GameKit.GKPlayer] 43 | ) { 44 | let result = VariantArray() 45 | for player in players { 46 | result.append(Variant(GKPlayer(player: player))) 47 | } 48 | base?.did_find_hosted_players.emit(result) 49 | } 50 | 51 | weak var base: GKMatchmakerViewController? 52 | init(_ base: GKMatchmakerViewController) { 53 | self.base = base 54 | } 55 | } 56 | 57 | @Signal("detail") var cancelled: SignalWithArguments 58 | 59 | /// Matchmaking has failed with an error 60 | @Signal("message") var failed_with_error: SignalWithArguments 61 | 62 | /// A peer-to-peer match has been found, the game should start 63 | @Signal("match") var did_find_match: SignalWithArguments 64 | 65 | /// Players have been found for a server-hosted game, the game should start, receives an array of GKPlayers 66 | @Signal("players") var did_find_hosted_players: SignalWithArguments 67 | 68 | /// The view controller if we create it 69 | var vc: GameKit.GKMatchmakerViewController? 70 | /// Delegate class if the user is rolling his own 71 | var proxy: Proxy? 72 | 73 | #if os(macOS) 74 | /// When the user triggers the presentation, on macOS, we keep track of it 75 | var dialogController: GKDialogController? = nil 76 | #endif 77 | 78 | /// Returns a view controller for the specified request, configure the various callbacks, and then 79 | /// call `present` on it. 80 | @Callable static func create_controller(request: GKMatchRequest) -> GKMatchmakerViewController? { 81 | MainActor.assumeIsolated { 82 | if let vc = GameKit.GKMatchmakerViewController(matchRequest: request.request) { 83 | let v = GKMatchmakerViewController() 84 | let proxy = Proxy(v) 85 | 86 | v.vc = vc 87 | v.proxy = proxy 88 | 89 | vc.matchmakerDelegate = proxy 90 | return v 91 | } 92 | return nil 93 | } 94 | } 95 | 96 | // This is used for the custom request that is vastly simpler than rolling your own 97 | class RequestMatchDelegate: NSObject, GameKit.GKMatchmakerViewControllerDelegate, @unchecked Sendable { 98 | #if os(macOS) 99 | var dialogController: GKDialogController? 100 | #endif 101 | private let callback: Callable 102 | let done: () -> () 103 | init(_ callback: Callable, done: @escaping () -> () = { }) { 104 | self.callback = callback 105 | self.done = done 106 | } 107 | 108 | func matchmakerViewController( 109 | _ viewController: GameKit.GKMatchmakerViewController, 110 | didFind match: GameKit.GKMatch 111 | ) { 112 | _ = self.callback.call(Variant(GKMatch(match: match)), nil) 113 | } 114 | 115 | func matchmakerViewControllerWasCancelled( 116 | _ source: GameKit.GKMatchmakerViewController 117 | ) { 118 | MainActor.assumeIsolated { 119 | #if os(iOS) 120 | source.dismiss(animated: true) 121 | #else 122 | dialogController?.dismiss(source) 123 | 124 | #endif 125 | _ = self.callback.call(nil, Variant("cancelled")) 126 | done() 127 | } 128 | } 129 | 130 | func matchmakerViewController( 131 | _ source: GameKit.GKMatchmakerViewController, 132 | didFailWithError: (any Error) 133 | ) { 134 | _ = self.callback.call(nil, Variant(didFailWithError.localizedDescription)) 135 | done() 136 | } 137 | } 138 | 139 | /// Convenience method that is a version that sets up `create_controller` and calls the callback 140 | /// with two arguments, the first is the match on success, and the second is an error on failure, which can be 141 | /// one of the following strings: "cancelled", 142 | @Callable static func request_match(request: GKMatchRequest, callback: Callable) { 143 | MainActor.assumeIsolated { 144 | if let vc = GameKit.GKMatchmakerViewController(matchRequest: request.request) { 145 | var hold: RequestMatchDelegate? 146 | 147 | hold = RequestMatchDelegate(callback, done: { 148 | hold = nil 149 | }) 150 | vc.matchmakerDelegate = hold 151 | GKMatchmakerViewController.present(controller: vc) { 152 | #if os(macOS) 153 | hold?.dialogController = $0 as? GKDialogController 154 | #endif 155 | } 156 | } 157 | } 158 | } 159 | 160 | @Callable func present() { 161 | guard let vc else { 162 | return 163 | } 164 | GKMatchmakerViewController.present(controller: vc) { v in 165 | #if os(macOS) 166 | dialogController = v as? GKDialogController 167 | #endif 168 | } 169 | } 170 | 171 | static func present(controller: GameKit.GKMatchmakerViewController, track: @MainActor (AnyObject) -> ()) { 172 | MainActor.assumeIsolated { 173 | #if os(iOS) 174 | presentOnTop(controller) 175 | #else 176 | let dialogController = GKDialogController.shared() 177 | dialogController.parentWindow = NSApplication.shared.mainWindow 178 | dialogController.present(controller) 179 | track(dialogController) 180 | #endif 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /doc_classes/GKLeaderboard.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Accesses Apple Game Center leaderboards and their entries. 5 | 6 | 7 | [code skip-lint]GKLeaderboard[/code] lets you fetch configured leaderboards, submit scores, and read player ranks as described in the Leaderboards section of [code skip-lint]GameCenterGuide.md[/code]. All operations mirror Apple's asynchronous APIs, so every method expects a [code skip-lint]Callable[/code] that receives the requested data plus an optional error [code skip-lint]Variant[/code]. See Apple's reference at [url=https://developer.apple.com/documentation/gamekit/gkleaderboard]Apple's GKLeaderboard documentation[/url]. 8 | 9 | Submitting a score (guide sample): 10 | [codeblocks] 11 | [gdscript] 12 | var local_player := GameCenterManager.new().local_player 13 | 14 | GKLeaderboard.load_leaderboards(["MyLeaderboard"], func(leaderboards: Array[GKLeaderboard], error: Variant) -> void: 15 | if error: 16 | print("Load leaderboard error %s" % error) 17 | return 18 | 19 | var score := 100 20 | var context := 0 21 | leaderboards[0].submit_score(score, context, local_player, func(submit_error: Variant) -> void: 22 | if submit_error: 23 | print("Error submitting leaderboard %s" % submit_error) 24 | ) 25 | ) 26 | [/gdscript] 27 | [/codeblocks] 28 | 29 | Loading all versus specific leaderboards: 30 | [codeblocks] 31 | [gdscript] 32 | # Loads all leaderboards configured for the app. 33 | GKLeaderboard.load_leaderboards([], func(all_leaderboards: Array[GKLeaderboard], error: Variant) -> void: 34 | print("Received %d leaderboards" % all_leaderboards.size()) 35 | ) 36 | 37 | # Loads only the identifiers you pass in. 38 | GKLeaderboard.load_leaderboards(["My leaderboard"], func(named_leaderboards: Array[GKLeaderboard], error: Variant) -> void: 39 | print("Received %d specific leaderboards" % named_leaderboards.size()) 40 | ) 41 | [/gdscript] 42 | [/codeblocks] 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | Loads the local player's score together with the specified [code skip-lint]Array[/code] of [code skip-lint]GKPlayer[/code] objects for the given time scope (use the [enum TimeScope] values). The callback receives [code](GKLeaderboardEntry local, Array[GKLeaderboardEntry] scores, Variant error)[/code] where the first entry can be [code]null[/code] if the local player has not posted a score. 54 | 55 | 56 | 57 | 58 | 59 | 60 | Downloads the leaderboard icon. The callback arguments are [code](Image image, Variant error)[/code] with exactly one being [code]null[/code], matching the [code skip-lint]load_image[/code] helper shown in the guide. 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | Fetches leaderboard metadata. Pass an empty array to load every leaderboard configured for the app, or provide specific identifiers. The callback receives [code skip-lint]Array[GKLeaderboard][/code] and a [code skip-lint]Variant[/code] error string ([code]null[/code] on success). 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | Loads leaderboard entries for the local player. The callback receives [code](GKLeaderboardEntry local, Array[GKLeaderboardEntry] scores, Variant range, Variant error)[/code] where the first entry can be [code]null[/code] if the local player has not posted a score. The value `range` is the number of total player count that matched the scope. Supply [enum PlayerScope] and [enum TimeScope] integers for the first two parameters. 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | Submits a score for the provided player. The callback receives a [code skip-lint]Variant[/code] with the error string or [code]null[/code] when the submission succeeds. See [code skip-lint]GameCenterGuide.md[/code] for an end-to-end example. 90 | 91 | 92 | 93 | 94 | 95 | Beta-only identifier for activity leaderboards (empty on platforms that do not expose the value). 96 | 97 | 98 | Dictionary copy of Apple's [code]activityProperties[/code], containing keys such as [code]eventStartDate[/code] when available. 99 | 100 | 101 | Leaderboard duration in seconds for recurring leaderboards. 102 | 103 | 104 | Group identifier that you configured in App Store Connect, or an empty string if not grouped. 105 | 106 | 107 | 108 | 109 | 110 | 111 | Display name shown to players. 112 | 113 | 114 | The Apple leaderboard type returned as an integer value: [code]0[/code] ([code skip-lint]classic[/code]), [code]1[/code] ([code skip-lint]recurring[/code]), or [code]2[/code] ([code skip-lint]unknown[/code]). 115 | 116 | 117 | 118 | 119 | A leaderboard that never expires, showing all-time rankings of all players. 120 | 121 | 122 | A leaderboard that recurs, allowing players a fresh start to compete and earn higher ranks in each ocurrence. 123 | 124 | 125 | 126 | 127 | Scans the current day. 128 | 129 | 130 | Restricts results to the current week. 131 | 132 | 133 | Returns scores across the entire history of the leaderboard. 134 | 135 | 136 | Loads data for all players of the game. 137 | 138 | 139 | Loads only data for friends of the local player. 140 | 141 | 142 | 143 | --------------------------------------------------------------------------------