pythonnative 0.25.0__py3-none-any.whl → 0.26.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. pythonnative/__init__.py +1 -1
  2. pythonnative/cli/pn.py +172 -12
  3. pythonnative/diagnostics.py +27 -0
  4. pythonnative/native_modules/app_state.py +2 -1
  5. pythonnative/native_modules/battery.py +3 -2
  6. pythonnative/native_modules/biometrics.py +2 -1
  7. pythonnative/native_modules/camera.py +16 -7
  8. pythonnative/native_modules/clipboard.py +3 -2
  9. pythonnative/native_modules/file_system.py +3 -2
  10. pythonnative/native_modules/haptics.py +7 -6
  11. pythonnative/native_modules/linking.py +62 -6
  12. pythonnative/native_modules/location.py +11 -8
  13. pythonnative/native_modules/net_info.py +2 -1
  14. pythonnative/native_modules/notifications.py +97 -6
  15. pythonnative/native_modules/permissions.py +3 -2
  16. pythonnative/native_modules/share.py +2 -1
  17. pythonnative/native_views/android.py +142 -122
  18. pythonnative/native_views/desktop.py +50 -49
  19. pythonnative/native_views/ios.py +228 -160
  20. pythonnative/project/android.py +15 -3
  21. pythonnative/project/builder.py +172 -70
  22. pythonnative/project/config.py +28 -6
  23. pythonnative/project/devices.py +291 -0
  24. pythonnative/project/doctor.py +5 -4
  25. pythonnative/project/ios.py +51 -77
  26. pythonnative/project/permissions.py +19 -1
  27. pythonnative/project/runtime_assets.py +78 -166
  28. pythonnative/templates/android_template/app/build.gradle +5 -0
  29. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
  30. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
  31. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  32. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  33. pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
  34. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  35. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  36. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  37. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  38. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +1 -1
  39. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +43 -41
  40. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  41. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +0 -0
  42. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
  43. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
  44. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,291 @@
1
+ """Device and simulator discovery for ``pn devices`` / ``pn run --device``.
2
+
3
+ Enumerates the three kinds of targets a PythonNative app can run on:
4
+
5
+ - **Android devices and emulators** via ``adb devices -l``.
6
+ - **iOS Simulators** via ``xcrun simctl list devices --json``.
7
+ - **Physical iOS devices** via ``xcrun devicectl list devices`` (Xcode
8
+ 15+; earlier Xcodes simply report no physical devices).
9
+
10
+ The shell-outs are isolated in ``list_*`` functions; the parsers are
11
+ pure functions over captured text/JSON so they can be unit tested
12
+ without any toolchain installed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import subprocess
20
+ import tempfile
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+ __all__ = [
26
+ "Device",
27
+ "list_devices",
28
+ "list_android_devices",
29
+ "list_ios_simulators",
30
+ "list_ios_physical_devices",
31
+ "find_device",
32
+ "parse_adb_devices",
33
+ "parse_simctl_devices",
34
+ "parse_devicectl_devices",
35
+ ]
36
+
37
+
38
+ @dataclass
39
+ class Device:
40
+ """One runnable target.
41
+
42
+ Attributes:
43
+ platform: ``"android"`` or ``"ios"``.
44
+ kind: ``"device"``, ``"emulator"``, or ``"simulator"``.
45
+ identifier: The stable id used to target the device (adb
46
+ serial, simulator UDID, or devicectl identifier).
47
+ name: Human-readable model or simulator name.
48
+ os_version: OS version string when known.
49
+ state: ``"booted"``, ``"connected"``, ``"shutdown"``, or
50
+ ``"offline"``.
51
+ """
52
+
53
+ platform: str
54
+ kind: str
55
+ identifier: str
56
+ name: str
57
+ os_version: str = ""
58
+ state: str = ""
59
+
60
+ @property
61
+ def is_ready(self) -> bool:
62
+ """Whether the target can be used right now (or booted on demand)."""
63
+ if self.kind == "simulator":
64
+ return self.state in ("booted", "shutdown")
65
+ return self.state in ("booted", "connected")
66
+
67
+ def format(self) -> str:
68
+ """Return one aligned listing row for the CLI."""
69
+ os_part = f" ({self.os_version})" if self.os_version else ""
70
+ return f" {self.identifier:<40} {self.kind:<10} {self.state:<10} {self.name}{os_part}"
71
+
72
+
73
+ # ======================================================================
74
+ # Android (adb)
75
+ # ======================================================================
76
+
77
+
78
+ def parse_adb_devices(output: str) -> List[Device]:
79
+ """Parse ``adb devices -l`` output into devices.
80
+
81
+ Args:
82
+ output: Raw stdout from ``adb devices -l``.
83
+
84
+ Returns:
85
+ Android devices and emulators (offline entries included, with
86
+ ``state="offline"``).
87
+ """
88
+ devices: List[Device] = []
89
+ for line in output.splitlines():
90
+ line = line.strip()
91
+ if not line or line.startswith("List of devices"):
92
+ continue
93
+ parts = line.split()
94
+ if len(parts) < 2:
95
+ continue
96
+ serial, state = parts[0], parts[1]
97
+ if state == "unauthorized":
98
+ state = "offline"
99
+ elif state == "device":
100
+ state = "connected"
101
+ model = ""
102
+ for token in parts[2:]:
103
+ if token.startswith("model:"):
104
+ model = token[len("model:") :].replace("_", " ")
105
+ kind = "emulator" if serial.startswith("emulator-") else "device"
106
+ devices.append(
107
+ Device(
108
+ platform="android",
109
+ kind=kind,
110
+ identifier=serial,
111
+ name=model or serial,
112
+ state=state if state in ("connected", "offline") else "offline",
113
+ )
114
+ )
115
+ return devices
116
+
117
+
118
+ def list_android_devices() -> List[Device]:
119
+ """Return connected Android devices/emulators (empty without adb)."""
120
+ try:
121
+ result = subprocess.run(["adb", "devices", "-l"], check=False, capture_output=True, text=True)
122
+ except FileNotFoundError:
123
+ return []
124
+ return parse_adb_devices(result.stdout or "")
125
+
126
+
127
+ # ======================================================================
128
+ # iOS Simulators (simctl)
129
+ # ======================================================================
130
+
131
+ _RUNTIME_RE = re.compile(r"iOS[.-](\d+)[.-](\d+)")
132
+
133
+
134
+ def parse_simctl_devices(data: Dict[str, Any]) -> List[Device]:
135
+ """Parse ``simctl list devices --json`` payload into devices.
136
+
137
+ Args:
138
+ data: The decoded JSON payload.
139
+
140
+ Returns:
141
+ Available iOS Simulators (unavailable ones are skipped).
142
+ """
143
+ devices: List[Device] = []
144
+ for runtime, entries in (data.get("devices") or {}).items():
145
+ match = _RUNTIME_RE.search(runtime)
146
+ os_version = f"iOS {match.group(1)}.{match.group(2)}" if match else ""
147
+ if "iOS" not in runtime:
148
+ continue
149
+ for entry in entries or []:
150
+ if not entry.get("isAvailable") or not entry.get("udid"):
151
+ continue
152
+ devices.append(
153
+ Device(
154
+ platform="ios",
155
+ kind="simulator",
156
+ identifier=str(entry["udid"]),
157
+ name=str(entry.get("name") or entry["udid"]),
158
+ os_version=os_version,
159
+ state="booted" if entry.get("state") == "Booted" else "shutdown",
160
+ )
161
+ )
162
+ return devices
163
+
164
+
165
+ def list_ios_simulators() -> List[Device]:
166
+ """Return available iOS Simulators (empty without Xcode)."""
167
+ try:
168
+ result = subprocess.run(
169
+ ["xcrun", "simctl", "list", "devices", "available", "--json"],
170
+ check=False,
171
+ capture_output=True,
172
+ text=True,
173
+ )
174
+ except FileNotFoundError:
175
+ return []
176
+ try:
177
+ data = json.loads(result.stdout or "{}")
178
+ except json.JSONDecodeError:
179
+ return []
180
+ return parse_simctl_devices(data)
181
+
182
+
183
+ # ======================================================================
184
+ # Physical iOS devices (devicectl)
185
+ # ======================================================================
186
+
187
+
188
+ def parse_devicectl_devices(data: Dict[str, Any]) -> List[Device]:
189
+ """Parse ``devicectl list devices --json-output`` payload into devices.
190
+
191
+ Args:
192
+ data: The decoded JSON payload.
193
+
194
+ Returns:
195
+ Physical iOS devices.
196
+ """
197
+ devices: List[Device] = []
198
+ for entry in (data.get("result") or {}).get("devices") or []:
199
+ props = entry.get("deviceProperties") or {}
200
+ hardware = entry.get("hardwareProperties") or {}
201
+ connection = entry.get("connectionProperties") or {}
202
+ identifier = str(entry.get("identifier") or "")
203
+ if not identifier:
204
+ continue
205
+ state = "connected" if connection.get("tunnelState") in ("connected", "available") else "offline"
206
+ os_version = props.get("osVersionNumber") or ""
207
+ devices.append(
208
+ Device(
209
+ platform="ios",
210
+ kind="device",
211
+ identifier=identifier,
212
+ name=str(props.get("name") or hardware.get("marketingName") or identifier),
213
+ os_version=f"iOS {os_version}" if os_version else "",
214
+ state=state,
215
+ )
216
+ )
217
+ return devices
218
+
219
+
220
+ def list_ios_physical_devices() -> List[Device]:
221
+ """Return physical iOS devices (empty without Xcode 15+)."""
222
+ with tempfile.TemporaryDirectory() as tmp:
223
+ out_path = Path(tmp) / "devices.json"
224
+ try:
225
+ subprocess.run(
226
+ ["xcrun", "devicectl", "list", "devices", "--json-output", str(out_path)],
227
+ check=False,
228
+ capture_output=True,
229
+ text=True,
230
+ timeout=30,
231
+ )
232
+ except (FileNotFoundError, subprocess.TimeoutExpired):
233
+ return []
234
+ if not out_path.is_file():
235
+ return []
236
+ try:
237
+ data = json.loads(out_path.read_text(encoding="utf-8"))
238
+ except (json.JSONDecodeError, OSError):
239
+ return []
240
+ return parse_devicectl_devices(data)
241
+
242
+
243
+ # ======================================================================
244
+ # Aggregation / selection
245
+ # ======================================================================
246
+
247
+
248
+ def list_devices(platform: Optional[str] = None) -> List[Device]:
249
+ """Return all known targets, optionally restricted to one platform.
250
+
251
+ Args:
252
+ platform: ``"android"``, ``"ios"``, or ``None`` for both.
253
+
254
+ Returns:
255
+ Devices ordered: Android first, then physical iOS devices, then
256
+ simulators (booted simulators before shutdown ones).
257
+ """
258
+ devices: List[Device] = []
259
+ if platform in (None, "android"):
260
+ devices.extend(list_android_devices())
261
+ if platform in (None, "ios"):
262
+ devices.extend(list_ios_physical_devices())
263
+ simulators = list_ios_simulators()
264
+ simulators.sort(key=lambda dev: (dev.state != "booted", dev.name))
265
+ devices.extend(simulators)
266
+ return devices
267
+
268
+
269
+ def find_device(devices: List[Device], query: str) -> Optional[Device]:
270
+ """Resolve ``query`` against ``devices`` (exact id, then name match).
271
+
272
+ Args:
273
+ devices: Candidate devices.
274
+ query: An identifier, exact name, or case-insensitive name
275
+ substring.
276
+
277
+ Returns:
278
+ The best match, or ``None``. Ready devices win ties.
279
+ """
280
+ ready_first = sorted(devices, key=lambda dev: not dev.is_ready)
281
+ for device in ready_first:
282
+ if device.identifier == query:
283
+ return device
284
+ lowered = query.lower()
285
+ for device in ready_first:
286
+ if device.name.lower() == lowered:
287
+ return device
288
+ for device in ready_first:
289
+ if lowered in device.name.lower():
290
+ return device
291
+ return None
@@ -17,7 +17,8 @@ from pathlib import Path
17
17
  from typing import List, Optional
18
18
 
19
19
  from . import icons
20
- from .config import IOS_SUPPORTED_PYTHON_VERSION, SUPPORTED_PYTHON_VERSIONS, AppConfig, ConfigError
20
+ from .config import SUPPORTED_PYTHON_VERSIONS, AppConfig, ConfigError
21
+ from .runtime_assets import PINNED_ASSETS
21
22
 
22
23
  OK = "ok"
23
24
  WARN = "warn"
@@ -154,13 +155,13 @@ def check_ios(config: Optional[AppConfig]) -> List[CheckResult]:
154
155
  results.append(CheckResult("xcrun simctl (Simulators)", OK if simctl else WARN, simctl or "not found on PATH"))
155
156
 
156
157
  if config is not None:
157
- if config.python_version != IOS_SUPPORTED_PYTHON_VERSION:
158
+ if config.python_version not in PINNED_ASSETS:
159
+ supported = ", ".join(sorted(PINNED_ASSETS))
158
160
  results.append(
159
161
  CheckResult(
160
162
  "iOS embedded Python",
161
163
  WARN,
162
- f"app.python_version={config.python_version}; only "
163
- f"{IOS_SUPPORTED_PYTHON_VERSION} has a pinned iOS build",
164
+ f"app.python_version={config.python_version}; pinned iOS builds " f"exist for {supported}",
164
165
  )
165
166
  )
166
167
  if config.ios.development_team:
@@ -9,30 +9,32 @@ which avoids fragile ``project.pbxproj`` edits. This module owns the
9
9
  parts that must live on disk:
10
10
 
11
11
  - **Info.plist.** Display name, supported orientations, permission usage
12
- descriptions (from ``[permissions]``), background modes, and any
13
- ``[ios].extra_info_plist`` keys.
12
+ descriptions (from ``[permissions]``), background modes, URL schemes
13
+ (from ``[app].url_schemes``), and any ``[ios].extra_info_plist`` keys.
14
+ - **Entitlements.** A ``.entitlements`` file generated when a declared
15
+ capability needs one (e.g., ``remote_notifications``).
14
16
  - **Branding.** The ``AppIcon`` asset and an optional ``Splash`` image
15
17
  set plus a generated ``LaunchScreen`` storyboard.
16
18
  - **Export options.** A plist for ``xcodebuild -exportArchive`` derived
17
19
  from ``[ios.signing]``.
18
20
 
19
- The embedded CPython runtime (``Python.framework``, the standard library,
20
- ``rubicon-objc``, and user packages) is copied into the built ``.app``
21
- *after* the build by [`embed_runtime`][pythonnative.project.ios.embed_runtime],
22
- since PythonKit loads it at runtime rather than linking it.
21
+ The embedded CPython runtime is *not* handled here: the bundled Xcode
22
+ template links ``Python.xcframework`` directly and installs the standard
23
+ library, app sources, and packages during the Xcode build (see the
24
+ "Install Python runtime" build phase). The
25
+ [`builder`][pythonnative.project.builder] stages the framework and the
26
+ Python sources into the project before invoking ``xcodebuild``.
23
27
  """
24
28
 
25
29
  from __future__ import annotations
26
30
 
27
31
  import plistlib
28
- import shutil
29
32
  from dataclasses import dataclass
30
33
  from pathlib import Path
31
34
  from typing import Callable, Dict, List, Optional
32
35
 
33
36
  from . import icons
34
37
  from .config import AppConfig
35
- from .runtime_assets import IOSRuntime
36
38
 
37
39
  PROJECT_NAME = "ios_template"
38
40
  """The fixed Xcode project/scheme/target name (kept stable on purpose)."""
@@ -43,6 +45,9 @@ PROJECT_FILE = "ios_template.xcodeproj"
43
45
  APP_BUNDLE_NAME = "ios_template.app"
44
46
  """The built ``.app`` bundle name (product name is left as the target name)."""
45
47
 
48
+ ENTITLEMENTS_FILE = "ios_template/ios_template.entitlements"
49
+ """Project-relative path of the generated entitlements file (when needed)."""
50
+
46
51
  _ORIENTATIONS: Dict[str, List[str]] = {
47
52
  "portrait": ["UIInterfaceOrientationPortrait"],
48
53
  "landscape": [
@@ -97,6 +102,7 @@ def configure(project_dir: Path, config: AppConfig, *, log: Optional[Logger] = N
97
102
  info_plist = project_dir / "ios_template" / "Info.plist"
98
103
 
99
104
  configure_info_plist(info_plist, config)
105
+ write_entitlements(project_dir, config)
100
106
  _apply_branding(project_dir, config, emit)
101
107
 
102
108
  emit(f"Configured iOS project ({config.bundle_id}).")
@@ -131,6 +137,14 @@ def configure_info_plist(info_plist: Path, config: AppConfig) -> None:
131
137
  if resolved.ios_background_modes:
132
138
  plist["UIBackgroundModes"] = list(resolved.ios_background_modes)
133
139
 
140
+ if config.url_schemes:
141
+ plist["CFBundleURLTypes"] = [
142
+ {
143
+ "CFBundleURLName": config.bundle_id,
144
+ "CFBundleURLSchemes": list(config.url_schemes),
145
+ }
146
+ ]
147
+
134
148
  if config.splash:
135
149
  plist["UILaunchStoryboardName"] = "LaunchScreen"
136
150
 
@@ -141,6 +155,27 @@ def configure_info_plist(info_plist: Path, config: AppConfig) -> None:
141
155
  plistlib.dump(plist, handle)
142
156
 
143
157
 
158
+ def write_entitlements(project_dir: Path, config: AppConfig) -> Optional[Path]:
159
+ """Generate the app entitlements file when a capability needs one.
160
+
161
+ Args:
162
+ project_dir: The staged ``ios_template`` directory.
163
+ config: The validated app configuration.
164
+
165
+ Returns:
166
+ The written entitlements path, or ``None`` when no declared
167
+ capability requires entitlements.
168
+ """
169
+ entitlements = config.resolved_permissions().ios_entitlements
170
+ if not entitlements:
171
+ return None
172
+ dest = project_dir / ENTITLEMENTS_FILE
173
+ dest.parent.mkdir(parents=True, exist_ok=True)
174
+ with open(dest, "wb") as handle:
175
+ plistlib.dump(dict(entitlements), handle)
176
+ return dest
177
+
178
+
144
179
  # ======================================================================
145
180
  # Build settings / export options
146
181
  # ======================================================================
@@ -170,17 +205,22 @@ def build_settings(config: AppConfig, *, for_archive: bool = False) -> List[str]
170
205
  ]
171
206
  if config.ios.development_team:
172
207
  settings.append(f"DEVELOPMENT_TEAM={config.ios.development_team}")
208
+ if config.resolved_permissions().ios_entitlements:
209
+ settings.append(f"CODE_SIGN_ENTITLEMENTS={ENTITLEMENTS_FILE}")
173
210
  if for_archive and not config.ios.signing.provisioning_profile:
174
211
  settings.append("CODE_SIGN_STYLE=Automatic")
175
212
  return settings
176
213
 
177
214
 
178
- def write_export_options(config: AppConfig, dest: Path) -> Path:
215
+ def write_export_options(config: AppConfig, dest: Path, *, upload: bool = False) -> Path:
179
216
  """Write an ``exportOptions.plist`` for ``xcodebuild -exportArchive``.
180
217
 
181
218
  Args:
182
219
  config: The validated app configuration.
183
220
  dest: Destination plist path.
221
+ upload: When ``True``, ask ``xcodebuild`` to upload the build to
222
+ App Store Connect instead of exporting a local ``.ipa``
223
+ (requires ``export_method = "app-store"``).
184
224
 
185
225
  Returns:
186
226
  ``dest``.
@@ -191,6 +231,8 @@ def write_export_options(config: AppConfig, dest: Path) -> Path:
191
231
  "compileBitcode": False,
192
232
  "stripSwiftSymbols": True,
193
233
  }
234
+ if upload:
235
+ options["destination"] = "upload"
194
236
  if config.ios.development_team:
195
237
  options["teamID"] = config.ios.development_team
196
238
  if signing.provisioning_profile:
@@ -274,71 +316,3 @@ _LAUNCH_STORYBOARD = """<?xml version="1.0" encoding="UTF-8"?>
274
316
  </resources>
275
317
  </document>
276
318
  """
277
-
278
-
279
- # ======================================================================
280
- # Runtime embedding (post-build)
281
- # ======================================================================
282
-
283
-
284
- def embed_runtime(
285
- app_bundle: Path,
286
- *,
287
- runtime: IOSRuntime,
288
- destination: str,
289
- python_sources: Path,
290
- site_packages: Optional[Path] = None,
291
- log: Optional[Logger] = None,
292
- ) -> None:
293
- """Copy the embedded CPython runtime and app code into a built ``.app``.
294
-
295
- PythonKit loads CPython at runtime, so the framework, standard
296
- library, app sources, and pure-Python site packages are copied into
297
- the bundle after the build/archive completes.
298
-
299
- Args:
300
- app_bundle: Path to the built ``.app`` directory.
301
- runtime: The resolved iOS runtime paths.
302
- destination: ``"simulator"`` or ``"device"`` (selects the slice).
303
- python_sources: Directory whose children (``app``,
304
- ``pythonnative``) are copied to the bundle root.
305
- site_packages: Optional directory of installed pure-Python
306
- packages (``rubicon-objc`` and user requirements) copied to
307
- ``platform-site``.
308
- log: Optional progress logger.
309
- """
310
- emit: Logger = log or (lambda _message: None)
311
- app_bundle = Path(app_bundle)
312
-
313
- framework = runtime.framework_for(destination)
314
- if framework and framework.is_dir():
315
- frameworks_dir = app_bundle / "Frameworks"
316
- frameworks_dir.mkdir(parents=True, exist_ok=True)
317
- shutil.copytree(framework, frameworks_dir / "Python.framework", dirs_exist_ok=True)
318
- emit("Embedded Python.framework.")
319
- else:
320
- emit(f"Warning: no Python.framework for {destination}; the app may not start.")
321
-
322
- stdlib = runtime.stdlib_for(destination)
323
- if stdlib and stdlib.is_dir():
324
- shutil.copytree(stdlib, app_bundle / "python-stdlib", dirs_exist_ok=True)
325
- emit("Embedded Python standard library.")
326
-
327
- if python_sources.is_dir():
328
- for child in python_sources.iterdir():
329
- target = app_bundle / child.name
330
- if child.is_dir():
331
- shutil.copytree(child, target, dirs_exist_ok=True)
332
- else:
333
- shutil.copy2(child, target)
334
-
335
- if site_packages and site_packages.is_dir():
336
- platform_site = app_bundle / "platform-site"
337
- platform_site.mkdir(parents=True, exist_ok=True)
338
- for child in site_packages.iterdir():
339
- target = platform_site / child.name
340
- if child.is_dir():
341
- shutil.copytree(child, target, dirs_exist_ok=True)
342
- else:
343
- shutil.copy2(child, target)
344
- emit("Embedded pure-Python site packages.")
@@ -55,6 +55,9 @@ class Capability:
55
55
  (e.g., ``"android.permission.CAMERA"``).
56
56
  ios_background_modes: ``UIBackgroundModes`` values to add
57
57
  (e.g., ``"location"``).
58
+ ios_entitlements: Code-signing entitlement key/value pairs the
59
+ capability requires (e.g., ``aps-environment`` for remote
60
+ push notifications).
58
61
  default_reason: Fallback usage description used when the
59
62
  capability is declared as ``true`` instead of a string.
60
63
  needs_reason: Whether iOS requires a usage description for this
@@ -68,6 +71,7 @@ class Capability:
68
71
  ios_usage_keys: Tuple[str, ...] = ()
69
72
  android_permissions: Tuple[str, ...] = ()
70
73
  ios_background_modes: Tuple[str, ...] = ()
74
+ ios_entitlements: Tuple[Tuple[str, object], ...] = ()
71
75
  default_reason: str = ""
72
76
  needs_reason: bool = True
73
77
 
@@ -196,11 +200,20 @@ CAPABILITIES: Dict[str, Capability] = {
196
200
  ),
197
201
  Capability(
198
202
  key="notifications",
199
- summary="Show local and push notifications.",
203
+ summary="Show local notifications.",
200
204
  ios_usage_keys=(),
201
205
  android_permissions=("android.permission.POST_NOTIFICATIONS",),
202
206
  needs_reason=False,
203
207
  ),
208
+ Capability(
209
+ key="remote_notifications",
210
+ summary="Receive remote (APNs) push notifications on iOS.",
211
+ ios_usage_keys=(),
212
+ android_permissions=("android.permission.POST_NOTIFICATIONS",),
213
+ ios_background_modes=("remote-notification",),
214
+ ios_entitlements=(("aps-environment", "development"),),
215
+ needs_reason=False,
216
+ ),
204
217
  Capability(
205
218
  key="vibration",
206
219
  summary="Trigger haptic feedback / vibration.",
@@ -249,12 +262,15 @@ class ResolvedPermissions:
249
262
  mapped to their reason strings.
250
263
  ios_background_modes: Ordered, de-duplicated ``UIBackgroundModes``
251
264
  values.
265
+ ios_entitlements: Code-signing entitlement keys mapped to their
266
+ values (empty when no capability needs entitlements).
252
267
  android_permissions: Ordered, de-duplicated Android permission
253
268
  names (including the always-on base set).
254
269
  """
255
270
 
256
271
  ios_usage_descriptions: Dict[str, str] = field(default_factory=dict)
257
272
  ios_background_modes: List[str] = field(default_factory=list)
273
+ ios_entitlements: Dict[str, object] = field(default_factory=dict)
258
274
  android_permissions: List[str] = field(default_factory=list)
259
275
 
260
276
 
@@ -318,6 +334,8 @@ def resolve_permissions(
318
334
  for mode in cap.ios_background_modes:
319
335
  if mode not in background:
320
336
  background.append(mode)
337
+ for entitlement_key, entitlement_value in cap.ios_entitlements:
338
+ resolved.ios_entitlements.setdefault(entitlement_key, entitlement_value)
321
339
  for perm in cap.android_permissions:
322
340
  if perm not in android:
323
341
  android.append(perm)