Skip to content

Commit db9efb1

Browse files
authored
Merge pull request #164 from shipth-is/163-feature-support-the-additional-entitlement-capabilities
feature: updated entitlement/capabilities parsing from export_presets.cfg and sync with Apple portal
2 parents 718e130 + b849f95 commit db9efb1

8 files changed

Lines changed: 446 additions & 42 deletions

File tree

docs/game/ios/app.md

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,24 +94,21 @@ EXAMPLES
9494

9595
#### Description
9696

97-
Synchronies the Apple App "BundleId" with the capabilities from the local project.
97+
Synchronizes the Apple App **Bundle ID** with the capabilities defined in your Godot iOS export preset.
9898

99-
This command will read your **export_presets.cfg** file and determine which capabilities
100-
to enable in the Apple Developer Portal.
99+
This command reads your **export_presets.cfg** (iOS preset) and enables or disables the corresponding capabilities on the Bundle ID in the Apple Developer Portal.
101100

102-
Currently, only the following permissions are supported:
101+
**Godot options that are synced**
103102

104-
- **Access WiFi**
105-
- **Push Notifications**
103+
| Godot option | Synced as | Notes |
104+
| --- | --- | --- |
105+
| capabilities/access_wifi | [Access WiFi](https://developer.apple.com/documentation/bundleresources/entitlements) | |
106+
| entitlements/increased_memory_limit | [Increased Memory Limit](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.kernel.increased-memory-limit) | |
107+
| entitlements/game_center | [Game Center](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.game-center) | |
108+
| entitlements/push_notifications | [Push Notifications](https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment) | Production or Development; or legacy **capabilities/push_notifications** (true). Sets aps-environment / APNS. |
109+
| entitlements/additional | [Entitlements](https://developer.apple.com/documentation/bundleresources/entitlements) (parsed) | Known entitlement keys (e.g. `com.apple.developer.applesignin` for Sign in with Apple) are synced. Unknown keys remain in your app’s entitlements only. |
106110

107-
:::warning
108-
109-
If your game uses other capabilities or if you are using plugins to enable certain
110-
features such as **GPS** or **file access**, please get in touch so that we can work with you.
111-
112-
**ShipThis is still in beta and we need your help to improve it.**
113-
114-
:::
111+
**Not synced:** Other export options (e.g. `capabilities/additional`, `capabilities/performance_gaming_tier`) only affect the exported app; they are not synced to the Bundle ID.
115112

116113
:::tip
117114
You do not need to have an **export_presets.cfg** file in your game directory.

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"fast-glob": "^3.3.2",
2525
"fs-extra": "^11.2.0",
2626
"fullscreen-ink": "^0.1.0",
27-
"godot-export-presets": "^0.1.6",
27+
"godot-export-presets": "^0.1.9",
2828
"ink": "^5.0.1",
2929
"ink-spinner": "^5.0.0",
3030
"isomorphic-git": "^1.27.1",

src/apple/entitlements.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import {CapabilityType} from './expo.js'
2+
3+
export type EntitlementKeyToCapability = Record<
4+
string,
5+
{name: string; type: (typeof CapabilityType)[keyof typeof CapabilityType]}
6+
>
7+
8+
/** Known entitlement keys in raw entitlements XML → CapabilityType and display name. */
9+
export const ENTITLEMENT_KEY_TO_CAPABILITY: EntitlementKeyToCapability = {
10+
'com.apple.developer.applesignin': {name: 'Sign in with Apple', type: CapabilityType.APPLE_ID_AUTH},
11+
'com.apple.developer.game-center': {name: 'Game Center', type: CapabilityType.GAME_CENTER},
12+
'com.apple.developer.healthkit': {name: 'HealthKit', type: CapabilityType.HEALTH_KIT},
13+
'com.apple.developer.healthkit.recalibrate-estimates': {
14+
name: 'HealthKit Recalibrate Estimates',
15+
type: CapabilityType.HEALTH_KIT_RECALIBRATE_ESTIMATES,
16+
},
17+
'com.apple.developer.homekit': {name: 'HomeKit', type: CapabilityType.HOME_KIT},
18+
'com.apple.developer.associated-domains': {
19+
name: 'Associated Domains',
20+
type: CapabilityType.ASSOCIATED_DOMAINS,
21+
},
22+
'com.apple.developer.authentication-services.autofill-credential-provider': {
23+
name: 'AutoFill Credential Provider',
24+
type: CapabilityType.AUTO_FILL_CREDENTIAL,
25+
},
26+
'com.apple.developer.ClassKit-environment': {name: 'ClassKit', type: CapabilityType.CLASS_KIT},
27+
'com.apple.developer.family-controls': {
28+
name: 'Family Controls',
29+
type: CapabilityType.FAMILY_CONTROLS,
30+
},
31+
'com.apple.developer.group-session': {
32+
name: 'Group Activities',
33+
type: CapabilityType.GROUP_ACTIVITIES,
34+
},
35+
'com.apple.developer.fileprovider.testing-mode': {
36+
name: 'File Provider Testing Mode',
37+
type: CapabilityType.FILE_PROVIDER_TESTING_MODE,
38+
},
39+
'com.apple.developer.networking.networkextension': {
40+
name: 'Network Extensions',
41+
type: CapabilityType.NETWORK_EXTENSIONS,
42+
},
43+
'com.apple.developer.networking.vpn.api': {
44+
name: 'Personal VPN',
45+
type: CapabilityType.PERSONAL_VPN,
46+
},
47+
'com.apple.developer.in-app-payments': {name: 'Apple Pay', type: CapabilityType.APPLE_PAY},
48+
'com.apple.developer.kernel.extended-virtual-addressing': {
49+
name: 'Extended Virtual Addressing',
50+
type: CapabilityType.EXTENDED_VIRTUAL_ADDRESSING,
51+
},
52+
'com.apple.developer.journal.allow': {
53+
name: 'Journaling Suggestions',
54+
type: CapabilityType.JOURNALING_SUGGESTIONS,
55+
},
56+
'com.apple.developer.managed-app-distribution.install-ui': {
57+
name: 'Managed App Installation UI',
58+
type: CapabilityType.MANAGED_APP_INSTALLATION_UI,
59+
},
60+
'com.apple.developer.media-device-discovery-extension': {
61+
name: 'Media Device Discovery',
62+
type: CapabilityType.MEDIA_DEVICE_DISCOVERY,
63+
},
64+
'com.apple.developer.matter.allow-setup-payload': {
65+
name: 'Matter Allow Setup Payload',
66+
type: CapabilityType.MATTER_ALLOW_SETUP_PAYLOAD,
67+
},
68+
'com.apple.developer.sustained-execution': {
69+
name: 'Sustained Execution',
70+
type: CapabilityType.SUSTAINED_EXECUTION,
71+
},
72+
'com.apple.security.application-groups': {name: 'App Groups', type: CapabilityType.APP_GROUP},
73+
}
74+
75+
const KEY_ELEMENT_REGEX = /<key>\s*([^<]+?)\s*<\/key>/g
76+
77+
/** Parse raw entitlements XML string for known keys; return their CapabilityTypes (no duplicates). Matches exact <key>…</key> elements to avoid substring false positives (e.g. healthkit vs healthkit.recalibrate-estimates). */
78+
export function parseEntitlementsAdditional(
79+
raw: string,
80+
): (typeof CapabilityType)[keyof typeof CapabilityType][] {
81+
const types: (typeof CapabilityType)[keyof typeof CapabilityType][] = []
82+
if (!raw || typeof raw !== 'string') return types
83+
let match: RegExpExecArray | null
84+
KEY_ELEMENT_REGEX.lastIndex = 0
85+
while ((match = KEY_ELEMENT_REGEX.exec(raw)) !== null) {
86+
const key = match[1].trim()
87+
const entry = ENTITLEMENT_KEY_TO_CAPABILITY[key]
88+
if (entry && !types.includes(entry.type)) types.push(entry.type)
89+
}
90+
return types
91+
}

src/commands/game/ios/profile/create.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,14 @@ export default class GameIosProfileCreate extends BaseGameCommand<typeof GameIos
8383
)
8484

8585
// Create the profile
86-
// TODO: only one of these can exist per bundleId - if forcing, should/can we disable existing ones?
86+
// We use the current YYYY-MM-DD HH:mm:ss as a unique identifier
87+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
88+
const name = `ShipThis Profile for ${iosBundleId} at ${timestamp}`
8789
const profile = await AppleProfile.createAsync(ctx, {
8890
bundleId: bundleId.id,
8991
certificates: [validAppleCert.id],
9092
devices: [],
91-
name: `ShipThis Profile for ${iosBundleId}`,
93+
name: name,
9294
profileType: AppleProfileType.IOS_APP_STORE,
9395
})
9496

src/utils/godot.ts

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import {
1414
ExportPresetsFile,
1515
} from 'godot-export-presets'
1616

17-
import {CapabilityType} from '@cli/apple/expo.js'
18-
import {Platform} from '@cli/types'
17+
import {ENTITLEMENT_KEY_TO_CAPABILITY, parseEntitlementsAdditional} from '../apple/entitlements.js'
18+
import {CapabilityType} from '../apple/expo.js'
19+
import {Platform} from '../types/index.js'
1920

2021
// Check if the current working directory is a Godot game
2122
// TODO: allow for cwd override
@@ -25,38 +26,106 @@ export function isCWDGodotGame(): boolean {
2526
return fs.existsSync(godotProject)
2627
}
2728

28-
// From the docs:
29-
// capabilities/access_wifi=true
30-
// capabilities/push_notifications=false
31-
// From the source code:
32-
// https://github.com/godotengine/godot/blob/b435551682f93cf49f606d260b28e13ff5526beb/platform/ios/export/export_plugin.cpp#L321
33-
// r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "capabilities/access_wifi"), false));
34-
// r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "capabilities/push_notifications"), false));
35-
// r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "capabilities/performance_gaming_tier"), false));
36-
// r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "capabilities/performance_a12"), false));
29+
// Godot iOS export options (entitlements & capabilities):
30+
// See EditorExportPlatformAppleEmbedded::get_export_options and
31+
// $entitlements_full / $required_device_capabilities in:
32+
// https://github.com/godotengine/godot/blob/master/editor/export/editor_export_platform_apple_embedded.cpp
33+
// We support both entitlements/push_notifications (enum, Godot 4.6+) and legacy capabilities/push_notifications (bool).
3734

38-
export const GODOT_CAPABILITIES = [
39-
// TODO: how about capabilities from godot extensions
35+
export interface GodotSyncableCapability {
36+
key: string
37+
name: string
38+
type: (typeof CapabilityType)[keyof typeof CapabilityType]
39+
/** If true, enabled by entitlements/push_notifications or legacy capabilities/push_notifications */
40+
pushKey?: boolean
41+
}
42+
43+
/** Syncable capabilities with a fixed preset key (bool or enum). Push is handled specially. */
44+
export const GODOT_SYNCABLE_CAPABILITIES: GodotSyncableCapability[] = [
4045
{key: 'capabilities/access_wifi', name: 'Access WiFi', type: CapabilityType.ACCESS_WIFI},
41-
{key: 'capabilities/push_notifications', name: 'Push Notifications', type: CapabilityType.PUSH_NOTIFICATIONS},
46+
{key: 'entitlements/increased_memory_limit', name: 'Increased Memory Limit', type: CapabilityType.INCREASED_MEMORY_LIMIT},
47+
{key: 'entitlements/game_center', name: 'Game Center', type: CapabilityType.GAME_CENTER},
48+
{key: 'entitlements/push_notifications', name: 'Push Notifications', type: CapabilityType.PUSH_NOTIFICATIONS, pushKey: true},
4249
]
4350

44-
// Tells us which capabilities are enabled in the Godot project
45-
export async function getGodotProjectCapabilities(platform: Platform) {
46-
const exportPresets = await getGodotExportPresets(platform)
51+
const syncableTypes = new Set(GODOT_SYNCABLE_CAPABILITIES.map((c) => c.type))
4752

48-
const options: Record<string, any> = exportPresets.options || {}
53+
/** All syncable capability entries for the Bundle ID table (fixed + from entitlements/additional, one row per type). */
54+
export const GODOT_CAPABILITIES: GodotSyncableCapability[] = [
55+
...GODOT_SYNCABLE_CAPABILITIES,
56+
...Object.entries(ENTITLEMENT_KEY_TO_CAPABILITY)
57+
.filter(([, {type}]) => !syncableTypes.has(type))
58+
.map(([key, {name, type}]) => ({
59+
key: `entitlements/additional (${key})`,
60+
name,
61+
type,
62+
})),
63+
]
4964

50-
const capabilities = []
65+
function isPushEnabled(options: Record<string, unknown>): boolean {
66+
const entitlementsPush = options['entitlements/push_notifications']
67+
if (entitlementsPush != null) {
68+
const s = `${entitlementsPush}`.trim().toLowerCase()
69+
if (s === 'production' || s === 'development') return true
70+
if (s === 'disabled') return false
71+
}
72+
const legacyPush = options['capabilities/push_notifications']
73+
if (legacyPush != null) {
74+
return `${legacyPush}`.toLowerCase() === 'true'
75+
}
76+
return false
77+
}
78+
79+
/** Optional override for testing; when set, skip getGodotExportPresets and use these options. */
80+
export interface GetGodotProjectCapabilitiesOverrides {
81+
options?: Record<string, unknown>
82+
}
5183

52-
for (const capability of GODOT_CAPABILITIES) {
84+
// Tells us which capabilities are enabled in the Godot project (for syncing to Apple Bundle ID).
85+
export async function getGodotProjectCapabilities(
86+
platform: Platform,
87+
overrides?: GetGodotProjectCapabilitiesOverrides,
88+
) {
89+
const options: Record<string, unknown> =
90+
overrides?.options ?? (await getGodotExportPresets(platform)).options ?? {}
91+
const capabilities: (typeof CapabilityType)[keyof typeof CapabilityType][] = []
92+
93+
for (const capability of GODOT_SYNCABLE_CAPABILITIES) {
94+
if (capability.pushKey) {
95+
if (isPushEnabled(options)) capabilities.push(capability.type)
96+
continue
97+
}
5398
if (!(capability.key in options)) continue
54-
if (`${options[capability.key]}`.toLocaleLowerCase() === 'true') capabilities.push(capability.type)
99+
if (`${options[capability.key]}`.toLowerCase() === 'true') capabilities.push(capability.type)
100+
}
101+
102+
const additionalRaw = options['entitlements/additional']
103+
const fromAdditional = parseEntitlementsAdditional(
104+
typeof additionalRaw === 'string' ? additionalRaw : '',
105+
)
106+
for (const type of fromAdditional) {
107+
if (!capabilities.includes(type)) capabilities.push(type)
55108
}
56109

57110
return capabilities
58111
}
59112

113+
/** Display-only: options that go to plist/device capabilities; we do not sync these. */
114+
export async function getGodotProjectDisplayOnlyCapabilities(platform: Platform): Promise<{
115+
performanceGamingTier: boolean
116+
performanceA12: boolean
117+
capabilitiesAdditional: string[]
118+
}> {
119+
const exportPresets = await getGodotExportPresets(platform)
120+
const options: Record<string, unknown> = exportPresets.options ?? {}
121+
const arr = options['capabilities/additional']
122+
return {
123+
performanceGamingTier: `${options['capabilities/performance_gaming_tier']}`.toLowerCase() === 'true',
124+
performanceA12: `${options['capabilities/performance_a12']}`.toLowerCase() === 'true',
125+
capabilitiesAdditional: Array.isArray(arr) ? arr.map((x) => `${x}`) : [],
126+
}
127+
}
128+
60129
export function getGodotProjectConfig(): ConfigFile {
61130
const cwd = process.cwd()
62131
const projectGodotPath = path.join(cwd, 'project.godot')

test/apple/entitlements.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {expect} from 'chai'
2+
3+
import {CapabilityType} from '../../src/apple/expo.js'
4+
import {
5+
ENTITLEMENT_KEY_TO_CAPABILITY,
6+
parseEntitlementsAdditional,
7+
} from '../../src/apple/entitlements.js'
8+
9+
describe('ENTITLEMENT_KEY_TO_CAPABILITY', () => {
10+
it('includes expected entitlement keys with correct CapabilityType', () => {
11+
expect(ENTITLEMENT_KEY_TO_CAPABILITY['com.apple.developer.applesignin']).to.deep.equal({
12+
name: 'Sign in with Apple',
13+
type: CapabilityType.APPLE_ID_AUTH,
14+
})
15+
expect(ENTITLEMENT_KEY_TO_CAPABILITY['com.apple.developer.healthkit']).to.deep.equal({
16+
name: 'HealthKit',
17+
type: CapabilityType.HEALTH_KIT,
18+
})
19+
expect(ENTITLEMENT_KEY_TO_CAPABILITY['com.apple.security.application-groups']).to.deep.equal({
20+
name: 'App Groups',
21+
type: CapabilityType.APP_GROUP,
22+
})
23+
})
24+
25+
it('has entries for all commonly used entitlement keys', () => {
26+
const expectedKeys = [
27+
'com.apple.developer.applesignin',
28+
'com.apple.developer.game-center',
29+
'com.apple.developer.healthkit',
30+
'com.apple.developer.homekit',
31+
'com.apple.developer.associated-domains',
32+
'com.apple.security.application-groups',
33+
]
34+
for (const key of expectedKeys) {
35+
expect(ENTITLEMENT_KEY_TO_CAPABILITY[key], `missing key: ${key}`).to.be.ok
36+
expect(ENTITLEMENT_KEY_TO_CAPABILITY[key].name).to.be.a('string')
37+
expect(ENTITLEMENT_KEY_TO_CAPABILITY[key].type).to.be.a('string')
38+
}
39+
})
40+
})
41+
42+
describe('parseEntitlementsAdditional', () => {
43+
it('returns empty array for empty string', () => {
44+
expect(parseEntitlementsAdditional('')).to.deep.equal([])
45+
})
46+
47+
it('returns matching CapabilityTypes for known keys in raw XML', () => {
48+
const raw = '<key>com.apple.developer.applesignin</key><array><string>Default</string></array>'
49+
expect(parseEntitlementsAdditional(raw)).to.include(CapabilityType.APPLE_ID_AUTH)
50+
})
51+
52+
it('returns multiple types when multiple known keys present', () => {
53+
const raw =
54+
'<key>com.apple.developer.healthkit</key><true/><key>com.apple.developer.homekit</key><true/>'
55+
const result = parseEntitlementsAdditional(raw)
56+
expect(result).to.include(CapabilityType.HEALTH_KIT)
57+
expect(result).to.include(CapabilityType.HOME_KIT)
58+
expect(result).to.have.lengthOf(2)
59+
})
60+
61+
it('returns no duplicates when same key appears multiple times', () => {
62+
const raw =
63+
'<key>com.apple.developer.healthkit</key><true/><key>com.apple.developer.healthkit</key>'
64+
const result = parseEntitlementsAdditional(raw)
65+
expect(result.filter((c) => c === CapabilityType.HEALTH_KIT)).to.have.lengthOf(1)
66+
})
67+
68+
it('ignores unknown keys', () => {
69+
expect(parseEntitlementsAdditional('<key>com.example.unknown</key><true/>')).to.deep.equal([])
70+
})
71+
72+
it('matches exact key elements only (no substring: healthkit.recalibrate-estimates does not match healthkit)', () => {
73+
const raw = '<key>com.apple.developer.healthkit.recalibrate-estimates</key><true/>'
74+
const result = parseEntitlementsAdditional(raw)
75+
expect(result).to.deep.equal([CapabilityType.HEALTH_KIT_RECALIBRATE_ESTIMATES])
76+
expect(result).not.to.include(CapabilityType.HEALTH_KIT)
77+
})
78+
})

0 commit comments

Comments
 (0)