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.
- pythonnative/__init__.py +1 -1
- pythonnative/cli/pn.py +172 -12
- pythonnative/diagnostics.py +27 -0
- pythonnative/native_modules/app_state.py +2 -1
- pythonnative/native_modules/battery.py +3 -2
- pythonnative/native_modules/biometrics.py +2 -1
- pythonnative/native_modules/camera.py +16 -7
- pythonnative/native_modules/clipboard.py +3 -2
- pythonnative/native_modules/file_system.py +3 -2
- pythonnative/native_modules/haptics.py +7 -6
- pythonnative/native_modules/linking.py +62 -6
- pythonnative/native_modules/location.py +11 -8
- pythonnative/native_modules/net_info.py +2 -1
- pythonnative/native_modules/notifications.py +97 -6
- pythonnative/native_modules/permissions.py +3 -2
- pythonnative/native_modules/share.py +2 -1
- pythonnative/native_views/android.py +142 -122
- pythonnative/native_views/desktop.py +50 -49
- pythonnative/native_views/ios.py +228 -160
- pythonnative/project/android.py +15 -3
- pythonnative/project/builder.py +172 -70
- pythonnative/project/config.py +28 -6
- pythonnative/project/devices.py +291 -0
- pythonnative/project/doctor.py +5 -4
- pythonnative/project/ios.py +51 -77
- pythonnative/project/permissions.py +19 -1
- pythonnative/project/runtime_assets.py +78 -166
- pythonnative/templates/android_template/app/build.gradle +5 -0
- pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
- pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
- pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
- pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
- pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
- pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
- pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
- pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
- {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +1 -1
- {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +43 -41
- pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
- {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
pythonnative/project/doctor.py
CHANGED
|
@@ -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
|
|
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
|
|
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};
|
|
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:
|
pythonnative/project/ios.py
CHANGED
|
@@ -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,
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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)
|