GitHub Viewer
"""Config-driven iOS project configurator.
Adapts the bundled ``ios_template`` Xcode project to a specific
[`AppConfig`][pythonnative.project.config.AppConfig]. Unlike Android, the
iOS bundle identifier, version, team, and deployment target are *not*
baked into files; they're passed to ``xcodebuild`` as build-setting
overrides (see [`build_settings`][pythonnative.project.ios.build_settings]),
which avoids fragile ``project.pbxproj`` edits. This module owns the
parts that must live on disk:
- **Info.plist.** Display name, supported orientations, permission usage
descriptions (from ``[permissions]``), background modes, URL schemes
(from ``[app].url_schemes``), and any ``[ios].extra_info_plist`` keys.
- **Entitlements.** A ``.entitlements`` file generated when a declared
capability needs one (e.g., ``remote_notifications``).
- **Branding.** The ``AppIcon`` asset and an optional ``Splash`` image
set plus a generated ``LaunchScreen`` storyboard.
- **Export options.** A plist for ``xcodebuild -exportArchive`` derived
from ``[ios.signing]``.
The embedded CPython runtime is *not* handled here: the bundled Xcode
template links ``Python.xcframework`` directly and installs the standard
library, app sources, and packages during the Xcode build (see the
"Install Python runtime" build phase). The
[`builder`][pythonnative.project.builder] stages the framework and the
Python sources into the project before invoking ``xcodebuild``.
"""
from __future__ import annotations
import plistlib
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, List, Optional
from . import icons
from .config import AppConfig
PROJECT_NAME = "ios_template"
"""The fixed Xcode project/scheme/target name (kept stable on purpose)."""
PROJECT_FILE = "ios_template.xcodeproj"
"""The ``.xcodeproj`` directory name within the staged template."""
APP_BUNDLE_NAME = "ios_template.app"
"""The built ``.app`` bundle name (product name is left as the target name)."""
ENTITLEMENTS_FILE = "ios_template/ios_template.entitlements"
"""Project-relative path of the generated entitlements file (when needed)."""
_ORIENTATIONS: Dict[str, List[str]] = {
"portrait": ["UIInterfaceOrientationPortrait"],
"landscape": [
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
],
"all": [
"UIInterfaceOrientationPortrait",
"UIInterfaceOrientationLandscapeLeft",
"UIInterfaceOrientationLandscapeRight",
],
}
_EXPORT_METHODS = {
"development": "development",
"ad-hoc": "ad-hoc",
"app-store": "app-store",
"enterprise": "enterprise",
}
Logger = Callable[[str], None]
@dataclass
class IOSLayout:
"""Resolved paths within a configured iOS project.
Attributes:
project_dir: The staged ``ios_template`` directory.
info_plist: Path to the app ``Info.plist``.
bundle_id: The resolved iOS bundle identifier.
"""
project_dir: Path
info_plist: Path
bundle_id: str
def configure(project_dir: Path, config: AppConfig, *, log: Optional[Logger] = None) -> IOSLayout:
"""Configure a staged iOS template for ``config``.
Args:
project_dir: The staged ``ios_template`` directory.
config: The validated app configuration.
log: Optional progress logger.
Returns:
An [`IOSLayout`][pythonnative.project.ios.IOSLayout].
"""
emit: Logger = log or (lambda _message: None)
project_dir = Path(project_dir)
info_plist = project_dir / "ios_template" / "Info.plist"
configure_info_plist(info_plist, config)
write_entitlements(project_dir, config)
_apply_branding(project_dir, config, emit)
emit(f"Configured iOS project ({config.bundle_id}).")
return IOSLayout(project_dir=project_dir, info_plist=info_plist, bundle_id=config.bundle_id)
# ======================================================================
# Info.plist
# ======================================================================
def configure_info_plist(info_plist: Path, config: AppConfig) -> None:
"""Write display name, orientation, permissions, and extras to the plist.
Args:
info_plist: Path to the app ``Info.plist``.
config: The validated app configuration.
"""
with open(info_plist, "rb") as handle:
plist = plistlib.load(handle)
plist["CFBundleDisplayName"] = config.display_name
plist["CFBundleName"] = config.name
plist["PNEntryModule"] = config.entry_module
orientations = _ORIENTATIONS.get(config.orientation, _ORIENTATIONS["portrait"])
plist["UISupportedInterfaceOrientations"] = list(orientations)
plist["UISupportedInterfaceOrientations~ipad"] = list(orientations)
resolved = config.resolved_permissions()
for key, reason in resolved.ios_usage_descriptions.items():
plist[key] = reason
# Debug builds connect to `pn start` over the LAN, which iOS gates
# behind the local-network prompt; the prompt needs this string.
plist.setdefault(
"NSLocalNetworkUsageDescription",
"Development builds connect to the PythonNative dev server on your computer.",
)
if resolved.ios_background_modes:
plist["UIBackgroundModes"] = list(resolved.ios_background_modes)
if config.url_schemes:
plist["CFBundleURLTypes"] = [
{
"CFBundleURLName": config.bundle_id,
"CFBundleURLSchemes": list(config.url_schemes),
}
]
if config.splash:
plist["UILaunchStoryboardName"] = "LaunchScreen"
for key, value in config.ios.extra_info_plist.items():
plist[key] = value
with open(info_plist, "wb") as handle:
plistlib.dump(plist, handle)
def write_entitlements(project_dir: Path, config: AppConfig) -> Optional[Path]:
"""Generate the app entitlements file when a capability needs one.
Args:
project_dir: The staged ``ios_template`` directory.
config: The validated app configuration.
Returns:
The written entitlements path, or ``None`` when no declared
capability requires entitlements.
"""
entitlements = config.resolved_permissions().ios_entitlements
if not entitlements:
return None
dest = project_dir / ENTITLEMENTS_FILE
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as handle:
plistlib.dump(dict(entitlements), handle)
return dest
# ======================================================================
# Build settings / export options
# ======================================================================
def build_settings(config: AppConfig, *, for_archive: bool = False) -> List[str]:
"""Return ``KEY=VALUE`` ``xcodebuild`` overrides for this config.
Args:
config: The validated app configuration.
for_archive: When ``True``, include signing settings appropriate
for a device archive.
Returns:
A list of ``"SETTING=value"`` strings to append to an
``xcodebuild`` invocation.
"""
orientations = _ORIENTATIONS.get(config.orientation, _ORIENTATIONS["portrait"])
orientation_value = " ".join(orientations)
settings = [
f"PRODUCT_BUNDLE_IDENTIFIER={config.bundle_id}",
f"MARKETING_VERSION={config.version}",
f"CURRENT_PROJECT_VERSION={config.build}",
f"IPHONEOS_DEPLOYMENT_TARGET={config.ios.deployment_target}",
f"INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone={orientation_value}",
f"INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad={orientation_value}",
]
if config.ios.development_team:
settings.append(f"DEVELOPMENT_TEAM={config.ios.development_team}")
if config.resolved_permissions().ios_entitlements:
settings.append(f"CODE_SIGN_ENTITLEMENTS={ENTITLEMENTS_FILE}")
if for_archive and not config.ios.signing.provisioning_profile:
settings.append("CODE_SIGN_STYLE=Automatic")
return settings
def write_export_options(config: AppConfig, dest: Path, *, upload: bool = False) -> Path:
"""Write an ``exportOptions.plist`` for ``xcodebuild -exportArchive``.
Args:
config: The validated app configuration.
dest: Destination plist path.
upload: When ``True``, ask ``xcodebuild`` to upload the build to
App Store Connect instead of exporting a local ``.ipa``
(requires ``export_method = "app-store"``).
Returns:
``dest``.
"""
signing = config.ios.signing
options: Dict[str, object] = {
"method": _EXPORT_METHODS.get(signing.export_method, "development"),
"compileBitcode": False,
"stripSwiftSymbols": True,
}
if upload:
options["destination"] = "upload"
if config.ios.development_team:
options["teamID"] = config.ios.development_team
if signing.provisioning_profile:
options["signingStyle"] = "manual"
options["provisioningProfiles"] = {config.bundle_id: signing.provisioning_profile}
else:
options["signingStyle"] = "automatic"
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as handle:
plistlib.dump(options, handle)
return dest
# ======================================================================
# Branding
# ======================================================================
def _apply_branding(project_dir: Path, config: AppConfig, emit: Logger) -> None:
assets_dir = project_dir / "ios_template" / "Assets.xcassets"
icon_path = config.resolve_path(config.icon) if config.icon else None
splash_path = config.resolve_path(config.splash) if config.splash else None
if icon_path and icons.has_source(icon_path):
if icons.generate_ios_icons(icon_path, assets_dir / "AppIcon.appiconset"):
emit("Generated iOS app icon.")
else:
emit("Skipping iOS icon: Pillow not installed (pip install 'pythonnative[build]').")
if splash_path and icons.has_source(splash_path):
if icons.generate_ios_splash(splash_path, assets_dir / "Splash.imageset"):
_write_launch_storyboard(project_dir)
emit("Configured iOS splash screen.")
else:
emit("Skipping iOS splash: Pillow not installed (pip install 'pythonnative[build]').")
def _write_launch_storyboard(project_dir: Path) -> None:
"""Overwrite ``LaunchScreen.storyboard`` with a centered splash image."""
storyboard = project_dir / "ios_template" / "Base.lproj" / "LaunchScreen.storyboard"
storyboard.write_text(_LAUNCH_STORYBOARD, encoding="utf-8")
# An edge-pinned image view (aspect-fit) over a white background. The
# image references the generated "Splash" asset set.
_LAUNCH_STORYBOARD = """
"""