|
| 1 | +import contextlib |
| 2 | +import os |
| 3 | +import plistlib |
| 4 | +import tempfile |
| 5 | +import zipfile |
| 6 | +from pathlib import Path |
| 7 | +from typing import TYPE_CHECKING, Any, Generic |
| 8 | + |
| 9 | +import zyncio |
| 10 | + |
| 11 | +from rpcclient.clients.darwin._types import DarwinSymbolT_co |
| 12 | +from rpcclient.core._types import ClientBound |
| 13 | +from rpcclient.exceptions import BadReturnValueError |
| 14 | +from rpcclient.utils import assert_cast |
| 15 | + |
| 16 | + |
| 17 | +if TYPE_CHECKING: |
| 18 | + from rpcclient.clients.ios.client import BaseIosClient |
| 19 | + |
| 20 | + |
| 21 | +MOBILE_UID = 501 |
| 22 | +MOBILE_GID = 501 |
| 23 | + |
| 24 | + |
| 25 | +class Installations(ClientBound["BaseIosClient[DarwinSymbolT_co]"], Generic[DarwinSymbolT_co]): |
| 26 | + """iOS application installation helpers. |
| 27 | +
|
| 28 | + This subsystem handles install flows that are not covered by the public |
| 29 | + MobileInstallation APIs exposed elsewhere. The methods operate through the |
| 30 | + connected RPC process and use remote filesystem, Objective-C, and |
| 31 | + LaunchServices calls on the target device. |
| 32 | + """ |
| 33 | + |
| 34 | + def __init__(self, client: "BaseIosClient[DarwinSymbolT_co]") -> None: |
| 35 | + """Bind the subsystem to an iOS client instance.""" |
| 36 | + self._client = client |
| 37 | + |
| 38 | + @zyncio.zmethod |
| 39 | + async def install_ipa(self, local_ipa: str | os.PathLike[str]) -> str: |
| 40 | + """Install an IPA's app bundle. |
| 41 | +
|
| 42 | + The IPA is expanded locally, its `Payload/*.app` bundle is copied to a |
| 43 | + temporary directory on the device, and MobileContainerManager is used to |
| 44 | + create the app and data containers for the bundle identifier found in |
| 45 | + `Info.plist`. The staged app bundle is moved into its final app |
| 46 | + container, ownership and permissions are normalized for the `mobile` |
| 47 | + user, and LaunchServices is asked to register the app dictionary. |
| 48 | +
|
| 49 | + This method assumes the IPA is already signed in a form the target |
| 50 | + device can execute. It does not modify entitlements, signatures, |
| 51 | + provisioning profiles, or embedded binaries. |
| 52 | +
|
| 53 | + :param local_ipa: Local path to the IPA archive. |
| 54 | + :return: Final remote `.app` bundle path on the device. |
| 55 | + :raises RuntimeError: If the IPA has no app bundle, staging fails, |
| 56 | + container creation fails, or LaunchServices registration fails. |
| 57 | + """ |
| 58 | + local_ipa = Path(local_ipa).expanduser().resolve() |
| 59 | + |
| 60 | + # Extract the IPA into a host-side temporary directory. IPA files are ZIP |
| 61 | + # archives and the installable bundle is expected under Payload/*.app. |
| 62 | + with tempfile.TemporaryDirectory(prefix="rpcipa-") as work_dir: |
| 63 | + work = Path(work_dir) |
| 64 | + with zipfile.ZipFile(local_ipa) as z: |
| 65 | + z.extractall(work) |
| 66 | + |
| 67 | + # Locate the app bundle inside the extracted payload. Only the first |
| 68 | + # bundle is installed, matching the behavior of the original helper. |
| 69 | + payload = work / "Payload" |
| 70 | + apps = list(payload.glob("*.app")) |
| 71 | + if not apps: |
| 72 | + raise ValueError("IPA has no Payload/*.app") |
| 73 | + |
| 74 | + local_app = apps[0] |
| 75 | + app_name = local_app.name |
| 76 | + |
| 77 | + # Read the app metadata locally. The bundle identifier drives both |
| 78 | + # MobileContainerManager container creation and LaunchServices |
| 79 | + # registration. |
| 80 | + info = plistlib.loads((local_app / "Info.plist").read_bytes()) |
| 81 | + bundle_id = info["CFBundleIdentifier"] |
| 82 | + |
| 83 | + # Use a per-host-process remote staging directory under /var/tmp so |
| 84 | + # the app can be uploaded before it is moved into its container. |
| 85 | + remote_stage = f"/var/tmp/rpcinstall-{os.getpid()}" |
| 86 | + remote_app_stage = f"{remote_stage}/{app_name}" |
| 87 | + |
| 88 | + try: |
| 89 | + # Create the remote staging directory and recursively upload the |
| 90 | + # extracted .app bundle into it. |
| 91 | + await self._client.fs.mkdir.z(remote_stage, parents=True, exist_ok=True) |
| 92 | + await self._client.fs.push.z(str(local_app), remote_stage, recursive=True, force=True) |
| 93 | + if not await self._client.fs.accessible.z(remote_app_stage, 0): |
| 94 | + raise RuntimeError(f"Failed to stage app bundle at {remote_app_stage}") |
| 95 | + |
| 96 | + # Load the private frameworks that provide container management |
| 97 | + # and LaunchServices registration classes. |
| 98 | + await self._client.load_framework.z("MobileContainerManager") |
| 99 | + await self._client.load_framework.z("CoreServices") |
| 100 | + |
| 101 | + # Resolve the Objective-C classes used for container allocation |
| 102 | + # and app registration. |
| 103 | + MCMAppContainer = await self._client.symbols.objc_getClass.z("MCMAppContainer") |
| 104 | + MCMAppDataContainer = await self._client.symbols.objc_getClass.z("MCMAppDataContainer") |
| 105 | + LSApplicationWorkspace = await self._client.symbols.objc_getClass.z("LSApplicationWorkspace") |
| 106 | + |
| 107 | + # Create or fetch the application container for this bundle ID. |
| 108 | + # The resulting URL is where the .app bundle should live. |
| 109 | + app_container = await MCMAppContainer.objc_call.z( |
| 110 | + "containerWithIdentifier:createIfNecessary:existed:error:", |
| 111 | + await self._client.cf.z(bundle_id), |
| 112 | + True, |
| 113 | + 0, |
| 114 | + 0, |
| 115 | + ) |
| 116 | + if not app_container: |
| 117 | + raise BadReturnValueError("Failed to create MCMAppContainer") |
| 118 | + |
| 119 | + app_container_dir = assert_cast( |
| 120 | + str, |
| 121 | + await (await (await app_container.objc_call.z("url")).objc_call.z("path")).py.z(), |
| 122 | + ) |
| 123 | + await self._client.fs.mkdir.z(app_container_dir, parents=True, exist_ok=True) |
| 124 | + final_app_path = f"{app_container_dir}/{app_name}" |
| 125 | + |
| 126 | + # Replace any existing app bundle at the target path before the |
| 127 | + # staged bundle is moved into its final location. |
| 128 | + if await self._client.fs.accessible.z(final_app_path, 0): |
| 129 | + await self._client.fs.remove.z(final_app_path, recursive=True, force=True) |
| 130 | + |
| 131 | + # Move the staged bundle into the app container and normalize |
| 132 | + # ownership and mode bits for iOS user-installed apps. |
| 133 | + await self._client.fs.rename.z(remote_app_stage, final_app_path) |
| 134 | + await self._client.fs.chown.z(final_app_path, MOBILE_UID, MOBILE_GID, recursive=True) |
| 135 | + await self._client.fs.chmod.z(final_app_path, 0o755, recursive=True) |
| 136 | + |
| 137 | + # Create or fetch the application data container. If this works, |
| 138 | + # the registration dictionary will point HOME and TMPDIR into it. |
| 139 | + data_container = await MCMAppDataContainer.objc_call.z( |
| 140 | + "containerWithIdentifier:createIfNecessary:existed:error:", |
| 141 | + await self._client.cf.z(bundle_id), |
| 142 | + True, |
| 143 | + 0, |
| 144 | + 0, |
| 145 | + ) |
| 146 | + |
| 147 | + data_container_dir = "" |
| 148 | + if data_container: |
| 149 | + data_container_dir = assert_cast( |
| 150 | + str, |
| 151 | + await (await (await data_container.objc_call.z("url")).objc_call.z("path")).py.z(), |
| 152 | + ) |
| 153 | + |
| 154 | + # Build the LaunchServices registration dictionary. These keys |
| 155 | + # describe the user app, its final path, signing metadata, and |
| 156 | + # install state closely enough for LS to make it launchable. |
| 157 | + reg: dict[str, Any] = { |
| 158 | + "ApplicationType": "User", |
| 159 | + "CFBundleIdentifier": bundle_id, |
| 160 | + "CodeInfoIdentifier": bundle_id, |
| 161 | + "CompatibilityState": 0, |
| 162 | + "IsContainerized": True, |
| 163 | + "IsDeletable": True, |
| 164 | + "Path": final_app_path, |
| 165 | + "SignerOrganization": "Apple Inc.", |
| 166 | + "SignatureVersion": 132352, |
| 167 | + "SignerIdentity": "Apple iPhone OS Application Signing", |
| 168 | + "IsAdHocSigned": True, |
| 169 | + "LSInstallType": 1, |
| 170 | + "HasMIDBasedSINF": False, |
| 171 | + "MissingSINF": False, |
| 172 | + "FamilyID": 0, |
| 173 | + "IsOnDemandInstallCapable": False, |
| 174 | + "_LSBundlePlugins": {}, |
| 175 | + } |
| 176 | + |
| 177 | + # Include container-specific environment variables when the data |
| 178 | + # container exists, matching what user apps expect at launch. |
| 179 | + if data_container_dir: |
| 180 | + reg["Container"] = data_container_dir |
| 181 | + reg["EnvironmentVariables"] = { |
| 182 | + "CFFIXED_USER_HOME": data_container_dir, |
| 183 | + "HOME": data_container_dir, |
| 184 | + "TMPDIR": f"{data_container_dir}/tmp", |
| 185 | + } |
| 186 | + |
| 187 | + # Ask LaunchServices to register the app. A false return means |
| 188 | + # the app bundle exists on disk but was not made launchable. |
| 189 | + workspace = await LSApplicationWorkspace.objc_call.z("defaultWorkspace") |
| 190 | + ok = await workspace.objc_call.z("registerApplicationDictionary:", await self._client.cf.z(reg)) |
| 191 | + if not ok: |
| 192 | + raise BadReturnValueError("LaunchServices registration failed") |
| 193 | + |
| 194 | + return final_app_path |
| 195 | + finally: |
| 196 | + # Always remove the temporary staging directory. Failures during |
| 197 | + # cleanup are suppressed, so the original installation error is kept. |
| 198 | + with contextlib.suppress(Exception): |
| 199 | + if await self._client.fs.accessible.z(remote_stage, 0): |
| 200 | + await self._client.fs.remove.z(remote_stage, recursive=True, force=True) |
0 commit comments