From fb33ae58e2c16cf5ccc32f4cbbdc209d7e0bd5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Mon, 29 Jan 2024 16:10:34 +0100 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=90=91=20naive=20alt-store=20suppor?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Naive shot at implementing alt store support. Might still be missing important bits and pices I'm not aware of. --- fdroidserver/common.py | 1 + fdroidserver/deploy.py | 1 + fdroidserver/update.py | 50 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/fdroidserver/common.py b/fdroidserver/common.py index 66dd106e..9e1a5340 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -4155,6 +4155,7 @@ def is_repo_file(filename, for_gpg_signing=False): if isinstance(filename, str): filename = filename.encode('utf-8', errors="surrogateescape") ignore_files = [ + b'altstore-index.json', b'entry.jar', b'index-v1.jar', b'index.css', diff --git a/fdroidserver/deploy.py b/fdroidserver/deploy.py index 2f8c4569..a2c60165 100644 --- a/fdroidserver/deploy.py +++ b/fdroidserver/deploy.py @@ -71,6 +71,7 @@ def _get_index_excludes(repo_section): """ indexes = [ + os.path.join(repo_section, 'altstore-index.json'), os.path.join(repo_section, 'entry.jar'), os.path.join(repo_section, 'entry.json'), os.path.join(repo_section, 'entry.json.asc'), diff --git a/fdroidserver/update.py b/fdroidserver/update.py index dd3bba50..abbcdb67 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -2390,6 +2390,48 @@ def prepare_apps(apps, apks, repodir): return apps_with_packages +def altstore_index(apps, apks, config, repodir, indent=None): + """build altstore index for iOS (.ipa) apps + + builds index files based on: + https://faq.altstore.io/distribute-your-apps/updating-apps + """ + + for lang in ['en']: + idx = { + 'name': config['repo_name'], + 'description': config['repo_description'], + 'apps': [], + } + + for packageName, app in apps.items(): + # print(app.keys()) + print( app['Name'],'.', app['AutoName']) + versions = [] + for apk in apks: + if apk['packageName'] == packageName and apk.get('apkName', '').lower().endswith('.ipa'): + v = { + "version": apk["versionName"], + # "buildVersion": "1", + "date": apk["added"].strftime("%Y-%m-%d"), + "localizedDescription": "", + "downloadURL": f"{config['repo_url']}/{apk['apkName']}", + "size": apk['size'], + "minOSVersion": "1.0", + "maxOSVersion": "18.0", + } + versions.append(v) + if len(versions) > 0: + idx['apps'].append({ + "name": app.get("Name") or app.get("AutoName"), + 'bundleIdentifier': packageName, + 'versions': versions, + }) + + with open(os.path.join(repodir, f'altstore-index.json'), "w", encoding="utf-8") as f: + json.dump(idx, f, indent=indent) + + config = None options = None start_timestamp = time.gmtime() @@ -2601,6 +2643,14 @@ def main(): # Make the index for the main repo... fdroidserver.index.make(repoapps, apks, repodirs[0], False) + print(repoapps) + altstore_index( + repoapps, + apks, + config, + repodirs[0], + indent=2 if options.pretty else None + ) git_remote = config.get('binary_transparency_remote') if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')): From 93e7cc9092399a852cc199c5eaf0a4cbec774ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Sun, 11 Feb 2024 18:48:25 +0100 Subject: [PATCH 02/13] =?UTF-8?q?=F0=9F=93=91=20better=20alt-store=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fdroidserver/update.py | 202 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 182 insertions(+), 20 deletions(-) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index abbcdb67..7f27ae7e 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -544,6 +544,60 @@ def version_string_to_int(version): return major * 10**12 + minor * 10**6 + patch +# iOS app permissions, source: +# https://developer.apple.com/documentation/bundleresources/information_property_list/protected_resources +IPA_PERMISSIONS = [ + "NSBluetoothAlwaysUsageDescription", + "NSBluetoothPeripheralUsageDescription", + "NSCalendarsFullAccessUsageDescription", + "NSCalendarsWriteOnlyAccessUsageDescription", + "NSRemindersFullAccessUsageDescription", + "NSCameraUsageDescription", + "NSMicrophoneUsageDescription", + "NSContactsUsageDescription", + "NSFaceIDUsageDescription", + "NSDesktopFolderUsageDescription", + "NSDocumentsFolderUsageDescription", + "NSDownloadsFolderUsageDescription", + "NSNetworkVolumesUsageDescription", + "NSNetworkVolumesUsageDescription", + "NSRemovableVolumesUsageDescription", + "NSRemovableVolumesUsageDescription", + "NSFileProviderDomainUsageDescription", + "NSGKFriendListUsageDescription", + "NSHealthClinicalHealthRecordsShareUsageDescription", + "NSHealthShareUsageDescription", + "NSHealthUpdateUsageDescription", + "NSHomeKitUsageDescription", + "NSLocationAlwaysAndWhenInUseUsageDescription", + "NSLocationUsageDescription", + "NSLocationWhenInUseUsageDescription", + "NSLocationAlwaysUsageDescription", + "NSAppleMusicUsageDescription", + "NSMotionUsageDescription", + "NSFallDetectionUsageDescription", + "NSLocalNetworkUsageDescription", + "NSNearbyInteractionUsageDescription", + "NSNearbyInteractionAllowOnceUsageDescription", + "NFCReaderUsageDescription", + "NSPhotoLibraryAddUsageDescription", + "NSPhotoLibraryUsageDescription", + "NSAppDataUsageDescription", + "NSUserTrackingUsageDescription", + "NSAppleEventsUsageDescription", + "NSSystemAdministrationUsageDescription", + "NSSensorKitUsageDescription", + "NSSiriUsageDescription", + "NSSpeechRecognitionUsageDescription", + "NSVideoSubscriberAccountUsageDescription", + "NSWorldSensingUsageDescription", + "NSHandsTrackingUsageDescription", + "NSIdentityUsageDescription", + "NSCalendarsUsageDescription", + "NSRemindersUsageDescription", +] + + def parse_ipa(ipa_path, file_size, sha256): from biplist import readPlist @@ -559,10 +613,17 @@ def parse_ipa(ipa_path, file_size, sha256): if re.match("Payload/[^/]*.app/Info.plist", info.filename): with ipa_zip.open(info) as plist_file: plist = readPlist(plist_file) + ipa["name"] = plist['CFBundleName'] ipa["packageName"] = plist["CFBundleIdentifier"] # https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring ipa["versionCode"] = version_string_to_int(plist["CFBundleShortVersionString"]) ipa["versionName"] = plist["CFBundleShortVersionString"] + ipa["ipa_MinimumOSVersion"] = plist['MinimumOSVersion'] + ipa["ipa_DTPlatformVersion"] = plist['DTPlatformVersion'] + ipa["ipa_permissions"] = {} + for ipap in IPA_PERMISSIONS: + if ipap in plist: + ipa["ipa_permissions"][ipap] = str(plist[ipap]) return ipa @@ -1351,6 +1412,21 @@ def insert_localized_ios_app_metadata(apps_with_packages): screenshots = fdroidserver.update.discover_ios_screenshots(fastlane_dir) fdroidserver.update.copy_ios_screenshots_to_repo(screenshots, package_name) + # lookup icons, copy them and put them into app + icon_path = _get_ipa_icon(pathlib.Path('build') / package_name) + icon_dest = pathlib.Path('repo') / package_name / f'icon.png' # for now just assume png + icon_stat = os.stat(icon_path) + app['iconv2'] = { + DEFAULT_LOCALE: { + 'name': str(icon_dest).lstrip('repo'), + 'sha256': common.sha256sum(icon_dest), + 'size': icon_stat.st_size, + } + } + if not icon_dest.exists(): + icon_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(icon_path, icon_dest) + def scan_repo_files(apkcache, repodir, knownapks, use_date_from_file=False): """Scan a repo for all files with an extension except APK/OBB/IPA. @@ -1548,6 +1624,55 @@ def _get_apk_icons_src(apkfile, icon_name): return icons_src +def _get_ipa_icon(src_dir): + """Searches source directory of an IPA project and tires to find an app icon.""" + # parse app icon name from project config file + src_dir = pathlib.Path(src_dir) + prj = next(src_dir.glob("**/project.pbxproj"), None) + if not prj or not prj.exists(): + return + + icon_name = _parse_from_pbxproj(prj, 'ASSETCATALOG_COMPILER_APPICON_NAME') + if not icon_name: + return + + icon_dir = next(src_dir.glob(f'**/{icon_name}.appiconset'), None) + if not icon_dir: + return + + with open(icon_dir / "Contents.json") as f: + cntnt = json.load(f) + + fname = None + fsize = 0 + for image in cntnt['images']: + s = float(image.get("size", "0x0").split("x")[0]) + if image.get('scale') == "1x" and s > fsize and s <= 128: + fname = image['filename'] + fsize = s + + return str(icon_dir / fname) + + +def _parse_from_pbxproj(pbxproj_path, key): + """Parse values from apple project files. + + e.g. when looking for key 'ASSETCATALOG_COMPILER_APPICON_NAME' + This function will extract 'MyIcon' from if the provided file + contains this line: + + ASSETCATALOG_COMPILER_APPICON_NAME = MyIcon; + + returns None if parsing for that value didn't yield anything + """ + r = re.compile(f"\\s*{key}\\s*=\\s*(?P[a-zA-Z0-9-_]+)\\s*;\\s*") + with open(pbxproj_path, 'r', encoding='utf-8') as f: + for line in f.readlines(): + m = r.match(line) + if m: + return m.group("value") + return None + def _sanitize_sdk_version(value): """Sanitize the raw values from androguard to handle bad values. @@ -2394,42 +2519,80 @@ def altstore_index(apps, apks, config, repodir, indent=None): """build altstore index for iOS (.ipa) apps builds index files based on: + https://faq.altstore.io/distribute-your-apps/make-a-source https://faq.altstore.io/distribute-your-apps/updating-apps """ + # for now we only support english for alt-store for lang in ['en']: + + # prepare minimal altstore index idx = { 'name': config['repo_name'], - 'description': config['repo_description'], - 'apps': [], + "apps": [], + "news": [], } + # add optional values if available + # idx["subtitle"] F-Droid doesn't have a corresponding value + if config.get("repo_description"): + idx['description'] = config['repo_description'] + if (pathlib.Path(repodir) / 'icons' / config['repo_icon']).exists(): + idx['iconURL'] = f"{config['repo_url']}/icons/{config['repo_icon']}" + # idx["headerURL"] F-Droid doesn't have a corresponding value + # idx["website"] F-Droid doesn't have a corresponding value + # idx["patreonURL"] F-Droid doesn't have a corresponding value + # idx["tintColor"] F-Droid doesn't have a corresponding value + # idx["featuredApps"] = [] maybe mappable to F-Droids what's new? + + # assemble "apps" for packageName, app in apps.items(): - # print(app.keys()) - print( app['Name'],'.', app['AutoName']) - versions = [] + app_name = app.get("Name") or app.get("AutoName") + a = { + "name": app_name, + 'bundleIdentifier': packageName, + 'developerName': app.get("AuthorName") or f"{app_name} team", + 'iconURL': app.get('iconv2', {}).get(DEFAULT_LOCALE, {}).get('name', ''), + "localizedDescription": "", + 'appPermissions': { + "entitlements": [], + "privacy": {}, + }, + 'versions': [], + } + + if app.get('summary'): + a['subtitle'] = app['summary'] + # a["tintColor"] F-Droid doesn't have a corresponding value + # a["category"] F-Droid doesn't have a corresponding value + # a['patreon'] F-Droid doesn't have a corresponding value + # a["screenshots"] TODO + + # populate 'versions' for apk in apks: if apk['packageName'] == packageName and apk.get('apkName', '').lower().endswith('.ipa'): v = { "version": apk["versionName"], - # "buildVersion": "1", - "date": apk["added"].strftime("%Y-%m-%d"), - "localizedDescription": "", + "date": apk["added"].isoformat(), "downloadURL": f"{config['repo_url']}/{apk['apkName']}", "size": apk['size'], - "minOSVersion": "1.0", - "maxOSVersion": "18.0", } - versions.append(v) - if len(versions) > 0: - idx['apps'].append({ - "name": app.get("Name") or app.get("AutoName"), - 'bundleIdentifier': packageName, - 'versions': versions, - }) - with open(os.path.join(repodir, f'altstore-index.json'), "w", encoding="utf-8") as f: - json.dump(idx, f, indent=indent) + # v['localizedDescription'] maybe what's new text? + v["minOSVersion"] = apk["ipa_MinimumOSVersion"] + v["maxOSVersion"] = apk["ipa_DTPlatformVersion"] + + # writing this spot here has the effect that always the + # permissions of the latest processed permissions list used + a['appPermissions']['privacy'] = apk['ipa_permissions'] + + a['versions'].append(v) + + if len(a['versions']) > 0: + idx['apps'].append(a) + + with open(os.path.join(repodir, f'altstore-index.json'), "w", encoding="utf-8") as f: + json.dump(idx, f, indent=indent) config = None @@ -2643,7 +2806,6 @@ def main(): # Make the index for the main repo... fdroidserver.index.make(repoapps, apks, repodirs[0], False) - print(repoapps) altstore_index( repoapps, apks, From 519c3c1fcfd69cdc6d44a514b397adee3aa16f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Mon, 12 Feb 2024 19:16:40 +0100 Subject: [PATCH 03/13] =?UTF-8?q?=F0=9F=91=91=20altstore=20index:=20add=20?= =?UTF-8?q?entitlement=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fdroidserver/update.py | 168 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 160 insertions(+), 8 deletions(-) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 7f27ae7e..75cee8b7 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -598,6 +598,147 @@ IPA_PERMISSIONS = [ ] +# known iOS app entitlements, source: +# https://developer.apple.com/documentation/bundleresources/entitlements +IPA_ENTITLEMENTS = [ + b"aps-environment", + b"com.apple.developer.ClassKit-environment", + b"com.apple.developer.applesignin", + b"com.apple.developer.aps-environment", + b"com.apple.developer.associated-appclip-app-identifiers", + b"com.apple.developer.associated-domains", + b"com.apple.developer.associated-domains.applinks.read-write", + b"com.apple.developer.authentication-services.autofill-credential-provider", + b"com.apple.developer.automated-device-enrollment.add-devices", + b"com.apple.developer.automatic-assessment-configuration", + b"com.apple.developer.avfoundation.multitasking-camera-access", + b"com.apple.developer.browser.app-installation", + b"com.apple.developer.carplay-audio", + b"com.apple.developer.carplay-charging", + b"com.apple.developer.carplay-communication", + b"com.apple.developer.carplay-maps", + b"com.apple.developer.carplay-messaging", + b"com.apple.developer.carplay-parking", + b"com.apple.developer.carplay-quick-ordering", + b"com.apple.developer.contacts.notes", + b"com.apple.developer.default-data-protection", + b"com.apple.developer.device-information.user-assigned-device-name", + b"com.apple.developer.devicecheck.appattest-environment", + b"com.apple.developer.driverkit", + b"com.apple.developer.driverkit.allow-any-userclient-access", + b"com.apple.developer.driverkit.allow-third-party-userclients", + b"com.apple.developer.driverkit.communicates-with-drivers", + b"com.apple.developer.driverkit.family.audio", + b"com.apple.developer.driverkit.family.block-storage-device", + b"com.apple.developer.driverkit.family.hid.device", + b"com.apple.developer.driverkit.family.hid.eventservice", + b"com.apple.developer.driverkit.family.networking", + b"com.apple.developer.driverkit.family.scsicontroller", + b"com.apple.developer.driverkit.family.serial", + b"com.apple.developer.driverkit.transport.hid", + b"com.apple.developer.driverkit.transport.pci", + b"com.apple.developer.driverkit.transport.usb", + b"com.apple.developer.driverkit.userclient-access", + b"com.apple.developer.endpoint-security.client", + b"com.apple.developer.endpoint-security.client", + b"com.apple.developer.exposure-notification", + b"com.apple.developer.family-controls", + b"com.apple.developer.fileprovider.testing-mode", + b"com.apple.developer.game-center", + b"com.apple.developer.group-session", + b"com.apple.developer.healthkit", + b"com.apple.developer.healthkit.access", + b"com.apple.developer.healthkit.background-delivery", + b"com.apple.developer.healthkit.recalibrate-estimates", + b"com.apple.developer.hid.virtual.device", + b"com.apple.developer.homekit", + b"com.apple.developer.icloud-container-development-container-identifiers", + b"com.apple.developer.icloud-container-environment", + b"com.apple.developer.icloud-container-identifiers", + b"com.apple.developer.icloud-services", + b"com.apple.developer.in-app-identity-presentment", + b"com.apple.developer.in-app-identity-presentment.merchant-identifiers", + b"com.apple.developer.in-app-payments", + b"com.apple.developer.journal.allow", + b"com.apple.developer.kernel.extended-virtual-addressing", + b"com.apple.developer.kernel.increased-memory-limit", + b"com.apple.developer.location.push", + b"com.apple.developer.mail-client", + b"com.apple.developer.managed-app-distribution.install-ui", + b"com.apple.developer.maps", + b"com.apple.developer.marketplace.app-installation", + b"com.apple.developer.matter.allow-setup-payload", + b"com.apple.developer.media-device-discovery-extension", + b"com.apple.developer.networking.HotspotConfiguration", + b"com.apple.developer.networking.custom-protocol", + b"com.apple.developer.networking.manage-thread-network-credentials", + b"com.apple.developer.networking.multicast", + b"com.apple.developer.networking.multipath", + b"com.apple.developer.networking.networkextension", + b"com.apple.developer.networking.networkextension", + b"com.apple.developer.networking.slicing.appcategory", + b"com.apple.developer.networking.slicing.trafficcategory", + b"com.apple.developer.networking.vmnet", + b"com.apple.developer.networking.vpn.api", + b"com.apple.developer.networking.wifi-info", + b"com.apple.developer.nfc.hce", + b"com.apple.developer.nfc.hce.default-contactless-app", + b"com.apple.developer.nfc.hce.iso7816.select-identifier-prefixes", + b"com.apple.developer.nfc.readersession.formats", + b"com.apple.developer.on-demand-install-capable", + b"com.apple.developer.parent-application-identifiers", + b"com.apple.developer.pass-type-identifiers", + b"com.apple.developer.playable-content", + b"com.apple.developer.proximity-reader.identity.display", + b"com.apple.developer.proximity-reader.identity.read", + b"com.apple.developer.push-to-talk", + b"com.apple.developer.sensitivecontentanalysis.client", + b"com.apple.developer.sensorkit.reader.allow", + b"com.apple.developer.severe-vehicular-crash-event", + b"com.apple.developer.siri", + b"com.apple.developer.storekit.external-link.account", + b"com.apple.developer.storekit.external-purchase", + b"com.apple.developer.storekit.external-purchase-link", + b"com.apple.developer.sustained-execution", + b"com.apple.developer.system-extension.install", + b"com.apple.developer.system-extension.redistributable", + b"com.apple.developer.team-identifier", + b"com.apple.developer.ubiquity-kvstore-identifier", + b"com.apple.developer.upi-device-validation", + b"com.apple.developer.user-management", + b"com.apple.developer.usernotifications.filtering", + b"com.apple.developer.video-subscriber-single-sign-on", + b"com.apple.developer.weatherkit", + b"com.apple.developer.web-browser", + b"com.apple.developer.web-browser.public-key-credential", + b"com.apple.external-accessory.wireless-configuration", + b"com.apple.security.app-sandbox", + b"com.apple.security.application-groups", + b"com.apple.security.automation.apple-events", + b"com.apple.security.cs.allow-dyld-environment-variables", + b"com.apple.security.cs.allow-jit", + b"com.apple.security.cs.allow-unsigned-executable-memory", + b"com.apple.security.cs.debugger", + b"com.apple.security.cs.disable-executable-page-protection", + b"com.apple.security.cs.disable-library-validation", + b"com.apple.security.device.audio-input", + b"com.apple.security.device.camera", + b"com.apple.security.hypervisor", + b"com.apple.security.personal-information.addressbook", + b"com.apple.security.personal-information.calendars", + b"com.apple.security.personal-information.location", + b"com.apple.security.personal-information.photos-library", + b"com.apple.security.smartcard", + b"com.apple.security.virtualization", + b"com.apple.smoot.subscriptionservice", + b"com.apple.vm.device-access", + b"com.apple.vm.hypervisor", + b"com.apple.vm.networking", + b"inter-app-audio", + b"keychain-access-groups", +] + + def parse_ipa(ipa_path, file_size, sha256): from biplist import readPlist @@ -606,6 +747,8 @@ def parse_ipa(ipa_path, file_size, sha256): "hash": sha256, "hashType": "sha256", "size": file_size, + "ipa_entitlements": set(), + "ipa_permissions": {}, } with zipfile.ZipFile(ipa_path) as ipa_zip: @@ -620,10 +763,17 @@ def parse_ipa(ipa_path, file_size, sha256): ipa["versionName"] = plist["CFBundleShortVersionString"] ipa["ipa_MinimumOSVersion"] = plist['MinimumOSVersion'] ipa["ipa_DTPlatformVersion"] = plist['DTPlatformVersion'] - ipa["ipa_permissions"] = {} for ipap in IPA_PERMISSIONS: if ipap in plist: ipa["ipa_permissions"][ipap] = str(plist[ipap]) + if info.filename.endswith("/embedded.mobileprovision"): + print("parsing", info.filename) + with ipa_zip.open(info) as mopro: + for line in mopro.readlines(): + for entitlement in IPA_ENTITLEMENTS: + if entitlement in line: + ipa['ipa_entitlements'].add(str(entitlement, encoding="utf-8")) + return ipa @@ -1414,7 +1564,7 @@ def insert_localized_ios_app_metadata(apps_with_packages): # lookup icons, copy them and put them into app icon_path = _get_ipa_icon(pathlib.Path('build') / package_name) - icon_dest = pathlib.Path('repo') / package_name / f'icon.png' # for now just assume png + icon_dest = pathlib.Path('repo') / package_name / 'icon.png' # for now just assume png icon_stat = os.stat(icon_path) app['iconv2'] = { DEFAULT_LOCALE: { @@ -1625,7 +1775,7 @@ def _get_apk_icons_src(apkfile, icon_name): def _get_ipa_icon(src_dir): - """Searches source directory of an IPA project and tires to find an app icon.""" + """Search source directory of an IPA project for the app icon.""" # parse app icon name from project config file src_dir = pathlib.Path(src_dir) prj = next(src_dir.glob("**/project.pbxproj"), None) @@ -1673,6 +1823,7 @@ def _parse_from_pbxproj(pbxproj_path, key): return m.group("value") return None + def _sanitize_sdk_version(value): """Sanitize the raw values from androguard to handle bad values. @@ -2516,14 +2667,14 @@ def prepare_apps(apps, apks, repodir): def altstore_index(apps, apks, config, repodir, indent=None): - """build altstore index for iOS (.ipa) apps + """ + Assemble altstore-index.json for iOS (.ipa) apps. builds index files based on: https://faq.altstore.io/distribute-your-apps/make-a-source https://faq.altstore.io/distribute-your-apps/updating-apps """ - - # for now we only support english for alt-store + # for now alt-store support is english only for lang in ['en']: # prepare minimal altstore index @@ -2555,7 +2706,7 @@ def altstore_index(apps, apks, config, repodir, indent=None): 'iconURL': app.get('iconv2', {}).get(DEFAULT_LOCALE, {}).get('name', ''), "localizedDescription": "", 'appPermissions': { - "entitlements": [], + "entitlements": set(), "privacy": {}, }, 'versions': [], @@ -2585,13 +2736,14 @@ def altstore_index(apps, apks, config, repodir, indent=None): # writing this spot here has the effect that always the # permissions of the latest processed permissions list used a['appPermissions']['privacy'] = apk['ipa_permissions'] + a['appPermissions']['entitlements'] = list(apk['ipa_entitlements']) a['versions'].append(v) if len(a['versions']) > 0: idx['apps'].append(a) - with open(os.path.join(repodir, f'altstore-index.json'), "w", encoding="utf-8") as f: + with open(os.path.join(repodir, 'altstore-index.json'), "w", encoding="utf-8") as f: json.dump(idx, f, indent=indent) From 2658c229339c08d8acad0e7190ca9584f28f6fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Sun, 10 Mar 2024 10:33:51 +0100 Subject: [PATCH 04/13] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20altstore=20index?= =?UTF-8?q?=20screenshots=20and=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fdroidserver/update.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 75cee8b7..54fb324c 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -2699,11 +2699,22 @@ def altstore_index(apps, apks, config, repodir, indent=None): # assemble "apps" for packageName, app in apps.items(): app_name = app.get("Name") or app.get("AutoName") + icon_url = "{}{}".format( + config['repo_url'], + app.get('iconv2', {}).get(DEFAULT_LOCALE, {}).get('name', ''), + ) + screenshot_urls = [ + "{}{}".format(config["repo_url"], s["name"]) + for s in app.get("screenshots", {}) + .get("phone", {}) + .get(DEFAULT_LOCALE, {}) + ] + a = { "name": app_name, 'bundleIdentifier': packageName, 'developerName': app.get("AuthorName") or f"{app_name} team", - 'iconURL': app.get('iconv2', {}).get(DEFAULT_LOCALE, {}).get('name', ''), + 'iconURL': icon_url, "localizedDescription": "", 'appPermissions': { "entitlements": set(), @@ -2717,7 +2728,7 @@ def altstore_index(apps, apks, config, repodir, indent=None): # a["tintColor"] F-Droid doesn't have a corresponding value # a["category"] F-Droid doesn't have a corresponding value # a['patreon'] F-Droid doesn't have a corresponding value - # a["screenshots"] TODO + a["screenshots"] = screenshot_urls # populate 'versions' for apk in apks: From 301f0c82737f004caeb8e6278c2248808a3c88b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Mon, 11 Mar 2024 03:11:53 +0100 Subject: [PATCH 05/13] =?UTF-8?q?=F0=9F=8D=8E=20altstore:=20implement=20ip?= =?UTF-8?q?a=20entitlement=20parser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a parser for reading entitlement values from .ipa files. Entitlement values are stored in files called '.../embedded.mobileprovision' packed into .ipa files. These are CMS signed plist files. https://en.wikipedia.org/wiki/Cryptographic_Message_Syntax This also ignores the 2 non-optional entitlements, as mentioned in altstore docs: https://faq.altstore.io/distribute-your-apps/make-a-source#entitlements-array-of-strings --- fdroidserver/update.py | 169 ++++------------------------------------- 1 file changed, 16 insertions(+), 153 deletions(-) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 54fb324c..cbf68d76 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -34,6 +34,7 @@ import json import time import yaml import copy +import asn1crypto.cms import defusedxml.ElementTree as ElementTree from datetime import datetime, timezone from argparse import ArgumentParser @@ -598,149 +599,8 @@ IPA_PERMISSIONS = [ ] -# known iOS app entitlements, source: -# https://developer.apple.com/documentation/bundleresources/entitlements -IPA_ENTITLEMENTS = [ - b"aps-environment", - b"com.apple.developer.ClassKit-environment", - b"com.apple.developer.applesignin", - b"com.apple.developer.aps-environment", - b"com.apple.developer.associated-appclip-app-identifiers", - b"com.apple.developer.associated-domains", - b"com.apple.developer.associated-domains.applinks.read-write", - b"com.apple.developer.authentication-services.autofill-credential-provider", - b"com.apple.developer.automated-device-enrollment.add-devices", - b"com.apple.developer.automatic-assessment-configuration", - b"com.apple.developer.avfoundation.multitasking-camera-access", - b"com.apple.developer.browser.app-installation", - b"com.apple.developer.carplay-audio", - b"com.apple.developer.carplay-charging", - b"com.apple.developer.carplay-communication", - b"com.apple.developer.carplay-maps", - b"com.apple.developer.carplay-messaging", - b"com.apple.developer.carplay-parking", - b"com.apple.developer.carplay-quick-ordering", - b"com.apple.developer.contacts.notes", - b"com.apple.developer.default-data-protection", - b"com.apple.developer.device-information.user-assigned-device-name", - b"com.apple.developer.devicecheck.appattest-environment", - b"com.apple.developer.driverkit", - b"com.apple.developer.driverkit.allow-any-userclient-access", - b"com.apple.developer.driverkit.allow-third-party-userclients", - b"com.apple.developer.driverkit.communicates-with-drivers", - b"com.apple.developer.driverkit.family.audio", - b"com.apple.developer.driverkit.family.block-storage-device", - b"com.apple.developer.driverkit.family.hid.device", - b"com.apple.developer.driverkit.family.hid.eventservice", - b"com.apple.developer.driverkit.family.networking", - b"com.apple.developer.driverkit.family.scsicontroller", - b"com.apple.developer.driverkit.family.serial", - b"com.apple.developer.driverkit.transport.hid", - b"com.apple.developer.driverkit.transport.pci", - b"com.apple.developer.driverkit.transport.usb", - b"com.apple.developer.driverkit.userclient-access", - b"com.apple.developer.endpoint-security.client", - b"com.apple.developer.endpoint-security.client", - b"com.apple.developer.exposure-notification", - b"com.apple.developer.family-controls", - b"com.apple.developer.fileprovider.testing-mode", - b"com.apple.developer.game-center", - b"com.apple.developer.group-session", - b"com.apple.developer.healthkit", - b"com.apple.developer.healthkit.access", - b"com.apple.developer.healthkit.background-delivery", - b"com.apple.developer.healthkit.recalibrate-estimates", - b"com.apple.developer.hid.virtual.device", - b"com.apple.developer.homekit", - b"com.apple.developer.icloud-container-development-container-identifiers", - b"com.apple.developer.icloud-container-environment", - b"com.apple.developer.icloud-container-identifiers", - b"com.apple.developer.icloud-services", - b"com.apple.developer.in-app-identity-presentment", - b"com.apple.developer.in-app-identity-presentment.merchant-identifiers", - b"com.apple.developer.in-app-payments", - b"com.apple.developer.journal.allow", - b"com.apple.developer.kernel.extended-virtual-addressing", - b"com.apple.developer.kernel.increased-memory-limit", - b"com.apple.developer.location.push", - b"com.apple.developer.mail-client", - b"com.apple.developer.managed-app-distribution.install-ui", - b"com.apple.developer.maps", - b"com.apple.developer.marketplace.app-installation", - b"com.apple.developer.matter.allow-setup-payload", - b"com.apple.developer.media-device-discovery-extension", - b"com.apple.developer.networking.HotspotConfiguration", - b"com.apple.developer.networking.custom-protocol", - b"com.apple.developer.networking.manage-thread-network-credentials", - b"com.apple.developer.networking.multicast", - b"com.apple.developer.networking.multipath", - b"com.apple.developer.networking.networkextension", - b"com.apple.developer.networking.networkextension", - b"com.apple.developer.networking.slicing.appcategory", - b"com.apple.developer.networking.slicing.trafficcategory", - b"com.apple.developer.networking.vmnet", - b"com.apple.developer.networking.vpn.api", - b"com.apple.developer.networking.wifi-info", - b"com.apple.developer.nfc.hce", - b"com.apple.developer.nfc.hce.default-contactless-app", - b"com.apple.developer.nfc.hce.iso7816.select-identifier-prefixes", - b"com.apple.developer.nfc.readersession.formats", - b"com.apple.developer.on-demand-install-capable", - b"com.apple.developer.parent-application-identifiers", - b"com.apple.developer.pass-type-identifiers", - b"com.apple.developer.playable-content", - b"com.apple.developer.proximity-reader.identity.display", - b"com.apple.developer.proximity-reader.identity.read", - b"com.apple.developer.push-to-talk", - b"com.apple.developer.sensitivecontentanalysis.client", - b"com.apple.developer.sensorkit.reader.allow", - b"com.apple.developer.severe-vehicular-crash-event", - b"com.apple.developer.siri", - b"com.apple.developer.storekit.external-link.account", - b"com.apple.developer.storekit.external-purchase", - b"com.apple.developer.storekit.external-purchase-link", - b"com.apple.developer.sustained-execution", - b"com.apple.developer.system-extension.install", - b"com.apple.developer.system-extension.redistributable", - b"com.apple.developer.team-identifier", - b"com.apple.developer.ubiquity-kvstore-identifier", - b"com.apple.developer.upi-device-validation", - b"com.apple.developer.user-management", - b"com.apple.developer.usernotifications.filtering", - b"com.apple.developer.video-subscriber-single-sign-on", - b"com.apple.developer.weatherkit", - b"com.apple.developer.web-browser", - b"com.apple.developer.web-browser.public-key-credential", - b"com.apple.external-accessory.wireless-configuration", - b"com.apple.security.app-sandbox", - b"com.apple.security.application-groups", - b"com.apple.security.automation.apple-events", - b"com.apple.security.cs.allow-dyld-environment-variables", - b"com.apple.security.cs.allow-jit", - b"com.apple.security.cs.allow-unsigned-executable-memory", - b"com.apple.security.cs.debugger", - b"com.apple.security.cs.disable-executable-page-protection", - b"com.apple.security.cs.disable-library-validation", - b"com.apple.security.device.audio-input", - b"com.apple.security.device.camera", - b"com.apple.security.hypervisor", - b"com.apple.security.personal-information.addressbook", - b"com.apple.security.personal-information.calendars", - b"com.apple.security.personal-information.location", - b"com.apple.security.personal-information.photos-library", - b"com.apple.security.smartcard", - b"com.apple.security.virtualization", - b"com.apple.smoot.subscriptionservice", - b"com.apple.vm.device-access", - b"com.apple.vm.hypervisor", - b"com.apple.vm.networking", - b"inter-app-audio", - b"keychain-access-groups", -] - - def parse_ipa(ipa_path, file_size, sha256): - from biplist import readPlist + import biplist ipa = { "apkName": os.path.basename(ipa_path), @@ -755,7 +615,7 @@ def parse_ipa(ipa_path, file_size, sha256): for info in ipa_zip.infolist(): if re.match("Payload/[^/]*.app/Info.plist", info.filename): with ipa_zip.open(info) as plist_file: - plist = readPlist(plist_file) + plist = biplist.readPlist(plist_file) ipa["name"] = plist['CFBundleName'] ipa["packageName"] = plist["CFBundleIdentifier"] # https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring @@ -768,12 +628,15 @@ def parse_ipa(ipa_path, file_size, sha256): ipa["ipa_permissions"][ipap] = str(plist[ipap]) if info.filename.endswith("/embedded.mobileprovision"): print("parsing", info.filename) - with ipa_zip.open(info) as mopro: - for line in mopro.readlines(): - for entitlement in IPA_ENTITLEMENTS: - if entitlement in line: - ipa['ipa_entitlements'].add(str(entitlement, encoding="utf-8")) - + with ipa_zip.open(info) as mopro_file: + mopro_content_info = cms.ContentInfo.load(mopro_file.read()) + mopro_payload_info = mopro_content_info['content'] + mopro_payload = mopro_payload_info['encap_content_info']['content'].native + mopro = biplist.readPlistFromString(mopro_payload) + # https://faq.altstore.io/distribute-your-apps/make-a-source#entitlements-array-of-strings + for entitlement in mopro.get('Entitlements', {}).keys(): + if entitlement not in ["com.app.developer.team-identifier", 'application-identifier']: + ipa["ipa_entitlements"].add(entitlement) return ipa @@ -1563,8 +1426,8 @@ def insert_localized_ios_app_metadata(apps_with_packages): fdroidserver.update.copy_ios_screenshots_to_repo(screenshots, package_name) # lookup icons, copy them and put them into app - icon_path = _get_ipa_icon(pathlib.Path('build') / package_name) - icon_dest = pathlib.Path('repo') / package_name / 'icon.png' # for now just assume png + icon_path = _get_ipa_icon(Path('build') / package_name) + icon_dest = Path('repo') / package_name / 'icon.png' # for now just assume png icon_stat = os.stat(icon_path) app['iconv2'] = { DEFAULT_LOCALE: { @@ -1777,7 +1640,7 @@ def _get_apk_icons_src(apkfile, icon_name): def _get_ipa_icon(src_dir): """Search source directory of an IPA project for the app icon.""" # parse app icon name from project config file - src_dir = pathlib.Path(src_dir) + src_dir = Path(src_dir) prj = next(src_dir.glob("**/project.pbxproj"), None) if not prj or not prj.exists(): return @@ -2688,7 +2551,7 @@ def altstore_index(apps, apks, config, repodir, indent=None): # idx["subtitle"] F-Droid doesn't have a corresponding value if config.get("repo_description"): idx['description'] = config['repo_description'] - if (pathlib.Path(repodir) / 'icons' / config['repo_icon']).exists(): + if (Path(repodir) / 'icons' / config['repo_icon']).exists(): idx['iconURL'] = f"{config['repo_url']}/icons/{config['repo_icon']}" # idx["headerURL"] F-Droid doesn't have a corresponding value # idx["website"] F-Droid doesn't have a corresponding value From a21ed3911700a1dbfc559570a328867a3a66bc77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Thu, 4 Apr 2024 11:16:18 +0200 Subject: [PATCH 06/13] =?UTF-8?q?=F0=9F=9B=BB=20move=20alstore=20index=20f?= =?UTF-8?q?unction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move function for generating altstore index from update.py to index.py --- fdroidserver/index.py | 99 ++++++++++++++++++++++++++++++++++++++++++ fdroidserver/update.py | 99 ------------------------------------------ 2 files changed, 99 insertions(+), 99 deletions(-) diff --git a/fdroidserver/index.py b/fdroidserver/index.py index 69237149..fb8a573f 100644 --- a/fdroidserver/index.py +++ b/fdroidserver/index.py @@ -125,6 +125,13 @@ def make(apps, apks, repodir, archive): make_v2(sortedapps, apks, repodir, repodict, requestsdict, fdroid_signing_key_fingerprints, archive) make_website(sortedapps, repodir, repodict) + make_altstore( + sortedapps, + apks, + common.config, + repodir, + indent=2 if common.options.pretty else None + ) def _should_file_be_generated(path, magic_string): @@ -1750,3 +1757,95 @@ def get_public_key_from_jar(jar): public_key_fingerprint = common.get_cert_fingerprint(public_key).replace(' ', '') return public_key, public_key_fingerprint + + +def make_altstore(apps, apks, config, repodir, indent=None): + """ + Assemble altstore-index.json for iOS (.ipa) apps. + + builds index files based on: + https://faq.altstore.io/distribute-your-apps/make-a-source + https://faq.altstore.io/distribute-your-apps/updating-apps + """ + # for now alt-store support is english only + for lang in ['en']: + + # prepare minimal altstore index + idx = { + 'name': config['repo_name'], + "apps": [], + "news": [], + } + + # add optional values if available + # idx["subtitle"] F-Droid doesn't have a corresponding value + if config.get("repo_description"): + idx['description'] = config['repo_description'] + if (Path(repodir) / 'icons' / config['repo_icon']).exists(): + idx['iconURL'] = f"{config['repo_url']}/icons/{config['repo_icon']}" + # idx["headerURL"] F-Droid doesn't have a corresponding value + # idx["website"] F-Droid doesn't have a corresponding value + # idx["patreonURL"] F-Droid doesn't have a corresponding value + # idx["tintColor"] F-Droid doesn't have a corresponding value + # idx["featuredApps"] = [] maybe mappable to F-Droids what's new? + + # assemble "apps" + for packageName, app in apps.items(): + app_name = app.get("Name") or app.get("AutoName") + icon_url = "{}{}".format( + config['repo_url'], + app.get('iconv2', {}).get(DEFAULT_LOCALE, {}).get('name', ''), + ) + screenshot_urls = [ + "{}{}".format(config["repo_url"], s["name"]) + for s in app.get("screenshots", {}) + .get("phone", {}) + .get(DEFAULT_LOCALE, {}) + ] + + a = { + "name": app_name, + 'bundleIdentifier': packageName, + 'developerName': app.get("AuthorName") or f"{app_name} team", + 'iconURL': icon_url, + "localizedDescription": "", + 'appPermissions': { + "entitlements": set(), + "privacy": {}, + }, + 'versions': [], + } + + if app.get('summary'): + a['subtitle'] = app['summary'] + # a["tintColor"] F-Droid doesn't have a corresponding value + # a["category"] F-Droid doesn't have a corresponding value + # a['patreon'] F-Droid doesn't have a corresponding value + a["screenshots"] = screenshot_urls + + # populate 'versions' + for apk in apks: + if apk['packageName'] == packageName and apk.get('apkName', '').lower().endswith('.ipa'): + v = { + "version": apk["versionName"], + "date": apk["added"].isoformat(), + "downloadURL": f"{config['repo_url']}/{apk['apkName']}", + "size": apk['size'], + } + + # v['localizedDescription'] maybe what's new text? + v["minOSVersion"] = apk["ipa_MinimumOSVersion"] + v["maxOSVersion"] = apk["ipa_DTPlatformVersion"] + + # writing this spot here has the effect that always the + # permissions of the latest processed permissions list used + a['appPermissions']['privacy'] = apk['ipa_permissions'] + a['appPermissions']['entitlements'] = list(apk['ipa_entitlements']) + + a['versions'].append(v) + + if len(a['versions']) > 0: + idx['apps'].append(a) + + with open(os.path.join(repodir, 'altstore-index.json'), "w", encoding="utf-8") as f: + json.dump(idx, f, indent=indent) diff --git a/fdroidserver/update.py b/fdroidserver/update.py index cbf68d76..5414c33e 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -2529,98 +2529,6 @@ def prepare_apps(apps, apks, repodir): return apps_with_packages -def altstore_index(apps, apks, config, repodir, indent=None): - """ - Assemble altstore-index.json for iOS (.ipa) apps. - - builds index files based on: - https://faq.altstore.io/distribute-your-apps/make-a-source - https://faq.altstore.io/distribute-your-apps/updating-apps - """ - # for now alt-store support is english only - for lang in ['en']: - - # prepare minimal altstore index - idx = { - 'name': config['repo_name'], - "apps": [], - "news": [], - } - - # add optional values if available - # idx["subtitle"] F-Droid doesn't have a corresponding value - if config.get("repo_description"): - idx['description'] = config['repo_description'] - if (Path(repodir) / 'icons' / config['repo_icon']).exists(): - idx['iconURL'] = f"{config['repo_url']}/icons/{config['repo_icon']}" - # idx["headerURL"] F-Droid doesn't have a corresponding value - # idx["website"] F-Droid doesn't have a corresponding value - # idx["patreonURL"] F-Droid doesn't have a corresponding value - # idx["tintColor"] F-Droid doesn't have a corresponding value - # idx["featuredApps"] = [] maybe mappable to F-Droids what's new? - - # assemble "apps" - for packageName, app in apps.items(): - app_name = app.get("Name") or app.get("AutoName") - icon_url = "{}{}".format( - config['repo_url'], - app.get('iconv2', {}).get(DEFAULT_LOCALE, {}).get('name', ''), - ) - screenshot_urls = [ - "{}{}".format(config["repo_url"], s["name"]) - for s in app.get("screenshots", {}) - .get("phone", {}) - .get(DEFAULT_LOCALE, {}) - ] - - a = { - "name": app_name, - 'bundleIdentifier': packageName, - 'developerName': app.get("AuthorName") or f"{app_name} team", - 'iconURL': icon_url, - "localizedDescription": "", - 'appPermissions': { - "entitlements": set(), - "privacy": {}, - }, - 'versions': [], - } - - if app.get('summary'): - a['subtitle'] = app['summary'] - # a["tintColor"] F-Droid doesn't have a corresponding value - # a["category"] F-Droid doesn't have a corresponding value - # a['patreon'] F-Droid doesn't have a corresponding value - a["screenshots"] = screenshot_urls - - # populate 'versions' - for apk in apks: - if apk['packageName'] == packageName and apk.get('apkName', '').lower().endswith('.ipa'): - v = { - "version": apk["versionName"], - "date": apk["added"].isoformat(), - "downloadURL": f"{config['repo_url']}/{apk['apkName']}", - "size": apk['size'], - } - - # v['localizedDescription'] maybe what's new text? - v["minOSVersion"] = apk["ipa_MinimumOSVersion"] - v["maxOSVersion"] = apk["ipa_DTPlatformVersion"] - - # writing this spot here has the effect that always the - # permissions of the latest processed permissions list used - a['appPermissions']['privacy'] = apk['ipa_permissions'] - a['appPermissions']['entitlements'] = list(apk['ipa_entitlements']) - - a['versions'].append(v) - - if len(a['versions']) > 0: - idx['apps'].append(a) - - with open(os.path.join(repodir, 'altstore-index.json'), "w", encoding="utf-8") as f: - json.dump(idx, f, indent=indent) - - config = None options = None start_timestamp = time.gmtime() @@ -2832,13 +2740,6 @@ def main(): # Make the index for the main repo... fdroidserver.index.make(repoapps, apks, repodirs[0], False) - altstore_index( - repoapps, - apks, - config, - repodirs[0], - indent=2 if options.pretty else None - ) git_remote = config.get('binary_transparency_remote') if git_remote or os.path.isdir(os.path.join('binary_transparency', '.git')): From 450765490b8f21cb8d0050052539741aa87c3575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Thu, 4 Apr 2024 13:04:55 +0200 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=97=BA=EF=B8=8F=20=20add=20test=20f?= =?UTF-8?q?or=20=5Fget=5Fipa=5Fico?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/update.TestCase | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/update.TestCase b/tests/update.TestCase index 81036cd6..0ce7a85c 100755 --- a/tests/update.TestCase +++ b/tests/update.TestCase @@ -2201,6 +2201,33 @@ class TestCopyIosScreenshotsToRepo(unittest.TestCase): ) +class TestGetIpaIcon(unittest.TestCase): + def test_get_ipa_icon(self): + self.maxDiff = None + + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir = Path(tmpdir) + (tmpdir / 'OnionBrowser.xcodeproj').mkdir() + with open(tmpdir / 'OnionBrowser.xcodeproj/project.pbxproj', "w") as f: + f.write("") + icondir = tmpdir / "fake_icon.appiconset" + icondir.mkdir() + with open(icondir / "Contents.json", "w", encoding="utf-8") as f: + f.write(""" + {"images": [ + {"scale": "2x", "size": "128x128", "filename": "nope"}, + {"scale": "1x", "size": "512x512", "filename": "nope"}, + {"scale": "1x", "size": "16x16", "filename": "nope"}, + {"scale": "1x", "size": "32x32", "filename": "yep"} + ]} + """) + + pfp = mock.Mock(return_value="fake_icon") + with(mock.patch("fdroidserver.update._parse_from_pbxproj", pfp)): + p = fdroidserver.update._get_ipa_icon(tmpdir) + self.assertEqual(str(icondir / "yep"), p) + + if __name__ == "__main__": os.chdir(os.path.dirname(__file__)) @@ -2221,4 +2248,5 @@ if __name__ == "__main__": newSuite.addTest(unittest.makeSuite(TestParseIosScreenShotName)) newSuite.addTest(unittest.makeSuite(TestInsertLocalizedIosAppMetadata)) newSuite.addTest(unittest.makeSuite(TestDiscoverIosScreenshots)) + newSuite.addTest(unittest.makeSuite(TestGetIpaIcon)) unittest.main(failfast=False) From f742799a9d485de535afc7e67bfb9497bbb52577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Thu, 4 Apr 2024 13:53:02 +0200 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=8F=9F=EF=B8=8F=20add=20test=20for?= =?UTF-8?q?=20=5Fparse=5Ffrom=5Fpbxproj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also fix lint issues --- fdroidserver/index.py | 4 ++-- fdroidserver/update.py | 6 +++++- tests/update.TestCase | 20 +++++++++++++++++++- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/fdroidserver/index.py b/fdroidserver/index.py index fb8a573f..a25c2926 100644 --- a/fdroidserver/index.py +++ b/fdroidserver/index.py @@ -1762,14 +1762,14 @@ def get_public_key_from_jar(jar): def make_altstore(apps, apks, config, repodir, indent=None): """ Assemble altstore-index.json for iOS (.ipa) apps. - + builds index files based on: https://faq.altstore.io/distribute-your-apps/make-a-source https://faq.altstore.io/distribute-your-apps/updating-apps """ # for now alt-store support is english only for lang in ['en']: - + # prepare minimal altstore index idx = { 'name': config['repo_name'], diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 5414c33e..6cd661a1 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -629,7 +629,7 @@ def parse_ipa(ipa_path, file_size, sha256): if info.filename.endswith("/embedded.mobileprovision"): print("parsing", info.filename) with ipa_zip.open(info) as mopro_file: - mopro_content_info = cms.ContentInfo.load(mopro_file.read()) + mopro_content_info = asn1crypto.cms.ContentInfo.load(mopro_file.read()) mopro_payload_info = mopro_content_info['content'] mopro_payload = mopro_payload_info['encap_content_info']['content'].native mopro = biplist.readPlistFromString(mopro_payload) @@ -1670,6 +1670,10 @@ def _get_ipa_icon(src_dir): def _parse_from_pbxproj(pbxproj_path, key): """Parse values from apple project files. + This is a naive regex based parser. Should this proofe to unreliable we + might want to consider using a dedicated pbxproj parser: + https://pypi.org/project/pbxproj/ + e.g. when looking for key 'ASSETCATALOG_COMPILER_APPICON_NAME' This function will extract 'MyIcon' from if the provided file contains this line: diff --git a/tests/update.TestCase b/tests/update.TestCase index 0ce7a85c..6f0b8654 100755 --- a/tests/update.TestCase +++ b/tests/update.TestCase @@ -2223,11 +2223,29 @@ class TestGetIpaIcon(unittest.TestCase): """) pfp = mock.Mock(return_value="fake_icon") - with(mock.patch("fdroidserver.update._parse_from_pbxproj", pfp)): + with mock.patch("fdroidserver.update._parse_from_pbxproj", pfp): p = fdroidserver.update._get_ipa_icon(tmpdir) self.assertEqual(str(icondir / "yep"), p) +class TestParseFromPbxproj(unittest.TestCase): + def test_parse_from_pbxproj(self): + self.maxDiff = None + + with tempfile.TemporaryDirectory() as tmpdir: + with open(Path(tmpdir) / "asdf.pbxproj", 'w', encoding="utf-8") as f: + f.write(""" + 230jfaod=flc' + ASSETCATALOG_COMPILER_APPICON_NAME = MyIcon; + cm opa1c p[m + """) + v = fdroidserver.update._parse_from_pbxproj( + Path(tmpdir) / "asdf.pbxproj", + "ASSETCATALOG_COMPILER_APPICON_NAME" + ) + self.assertEqual(v, "MyIcon") + + if __name__ == "__main__": os.chdir(os.path.dirname(__file__)) From 86db8c93cc1bb30b1cbc0d39680bd953fec98ed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Thu, 4 Apr 2024 14:14:58 +0200 Subject: [PATCH 09/13] =?UTF-8?q?=F0=9F=A9=B9=20fix=20parse=5Fipa=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/update.TestCase | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/update.TestCase b/tests/update.TestCase index 6f0b8654..2ab19a3a 100755 --- a/tests/update.TestCase +++ b/tests/update.TestCase @@ -1936,7 +1936,10 @@ class UpdateTest(unittest.TestCase): index['repo'][CATEGORIES_CONFIG_NAME], ) + +class TestParseIpa(unittest.TestCase): def test_parse_ipa(self): + self.maxDiff = None try: import biplist # Fedora does not have a biplist package @@ -1959,6 +1962,27 @@ class UpdateTest(unittest.TestCase): 'size': 'fake_size', 'versionCode': 1000000000001, 'versionName': '1.0.1', + 'ipa_DTPlatformVersion': '16.4', + 'ipa_MinimumOSVersion': '15.0', + 'ipa_entitlements': set(), + 'ipa_permissions': { + 'NSCameraUsageDescription': + 'Please allow access to your ' + 'camera, if you want to ' + 'create photos or videos for ' + 'direct sharing.', + 'NSMicrophoneUsageDescription': + 'Please allow access to ' + 'your microphone, if you ' + 'want to create videos ' + 'for direct sharing.', + 'NSPhotoLibraryUsageDescription': + 'Please allow access to ' + 'your photo library, if ' + 'you want to share ' + 'photos.', + }, + 'name': 'OnionShare', }, ) From 45efb88f852146f9f5d88748450620af2378a268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Thu, 4 Apr 2024 16:43:56 +0200 Subject: [PATCH 10/13] =?UTF-8?q?=F0=9F=95=B4=EF=B8=8F=20add=20test=20for?= =?UTF-8?q?=20make=5Faltstore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fdroidserver/index.py | 2 +- tests/index.TestCase | 91 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/fdroidserver/index.py b/fdroidserver/index.py index a25c2926..601cd9ce 100644 --- a/fdroidserver/index.py +++ b/fdroidserver/index.py @@ -1847,5 +1847,5 @@ def make_altstore(apps, apks, config, repodir, indent=None): if len(a['versions']) > 0: idx['apps'].append(a) - with open(os.path.join(repodir, 'altstore-index.json'), "w", encoding="utf-8") as f: + with open(Path(repodir) / 'altstore-index.json', "w", encoding="utf-8") as f: json.dump(idx, f, indent=indent) diff --git a/tests/index.TestCase b/tests/index.TestCase index 8eccec76..04a35c4b 100755 --- a/tests/index.TestCase +++ b/tests/index.TestCase @@ -829,6 +829,96 @@ class IndexTest(unittest.TestCase): index.add_mirrors_to_repodict('repo', repodict) +class AltstoreIndexTest(unittest.TestCase): + def test_make_altstore(self): + self.maxDiff = None + + apps = { + "app.fake": { + "AutoName": "Fake App", + "AuthorName": "Fake Author", + "iconv2": {"en_US": "fake_icon.png"}, + } + } + apks = [ + { + "packageName": "app.fake", + "apkName": "app.fake_123.ipa", + "versionName": "v123", + "added": datetime.datetime(2000, 2, 2, 2, 2, 2), + "size": 123, + "ipa_MinimumOSVersion": "10.0", + "ipa_DTPlatformVersion": "12.0", + "ipa_permissions": [ + "NSCameraUsageDescription", + "NSDocumentsFolderUsageDescription", + ], + "ipa_entitlements": [ + "com.apple.developer.team-identifier", + "com.apple.developer.web-browser", + "keychain-access-groups", + ], + }, + ] + config = { + "repo_icon": "fake_repo_icon.png", + "repo_name": "fake_repo", + "repo_url": "gopher://fake-repo.com/fdroid/repo" + } + + with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): + repodir = Path(tmpdir) / 'repo' + repodir.mkdir() + + fdroidserver.index.make_altstore( + apps, + apks, + config, + repodir, + 2, + ) + + with open(repodir / "altstore-index.json", 'r') as f: + self.assertDictEqual( + { + "apps": [ + { + "appPermissions": { + "entitlements": [ + 'com.apple.developer.team-identifier', + 'com.apple.developer.web-browser', + 'keychain-access-groups', + ], + 'privacy': [ + 'NSCameraUsageDescription', + 'NSDocumentsFolderUsageDescription', + ], + }, + 'bundleIdentifier': 'app.fake', + 'developerName': 'Fake Author', + 'iconURL': 'gopher://fake-repo.com/fdroid/repo', + 'localizedDescription': '', + 'name': 'Fake App', + 'screenshots': [], + 'versions': [ + { + 'date': '2000-02-02T02:02:02', + 'downloadURL': 'gopher://fake-repo.com/fdroid/repo/app.fake_123.ipa', + 'maxOSVersion': '12.0', + 'minOSVersion': '10.0', + 'size': 123, + 'version': 'v123', + } + ], + }, + ], + 'name': 'fake_repo', + 'news': [], + }, + json.load(f) + ) + + if __name__ == "__main__": os.chdir(os.path.dirname(__file__)) @@ -845,4 +935,5 @@ if __name__ == "__main__": newSuite = unittest.TestSuite() newSuite.addTest(unittest.makeSuite(IndexTest)) + newSuite.addTest(unittest.makeSuite(AltstoreIndexTest)) unittest.main(failfast=False) From f2118b35a32a3a2ebb99c90dc4390c795deb7e04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Tue, 9 Apr 2024 14:53:00 +0200 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=8F=9F=EF=B8=8F=20fix=20ci?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/deploy.TestCase | 6 ++++++ tests/index.TestCase | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/deploy.TestCase b/tests/deploy.TestCase index bbb0e929..fd17d062 100755 --- a/tests/deploy.TestCase +++ b/tests/deploy.TestCase @@ -112,6 +112,8 @@ class DeployTest(unittest.TestCase): fdroidserver.deploy.update_serverwebroot('serverwebroot', 'repo') def test_update_serverwebroot_make_cur_version_link(self): + self.maxDiff = None + # setup parameters for this test run fdroidserver.deploy.options = mock.Mock() fdroidserver.deploy.options.no_checksum = True @@ -137,6 +139,8 @@ class DeployTest(unittest.TestCase): '--safe-links', '--quiet', '--exclude', + 'repo/altstore-index.json', + '--exclude', 'repo/entry.jar', '--exclude', 'repo/entry.json', @@ -232,6 +236,8 @@ class DeployTest(unittest.TestCase): 'ssh -oBatchMode=yes -oIdentitiesOnly=yes -i ' + fdroidserver.deploy.config['identity_file'], '--exclude', + 'archive/altstore-index.json', + '--exclude', 'archive/entry.jar', '--exclude', 'archive/entry.json', diff --git a/tests/index.TestCase b/tests/index.TestCase index 04a35c4b..49fda126 100755 --- a/tests/index.TestCase +++ b/tests/index.TestCase @@ -863,7 +863,7 @@ class AltstoreIndexTest(unittest.TestCase): config = { "repo_icon": "fake_repo_icon.png", "repo_name": "fake_repo", - "repo_url": "gopher://fake-repo.com/fdroid/repo" + "repo_url": "gopher://fake-repo.com/fdroid/repo", } with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir): @@ -915,7 +915,7 @@ class AltstoreIndexTest(unittest.TestCase): 'name': 'fake_repo', 'news': [], }, - json.load(f) + json.load(f), ) From d00a87ed6c7be8b03a5d8bf688472e9309c2ac09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20P=C3=B6hn?= Date: Tue, 23 Apr 2024 17:28:30 +0200 Subject: [PATCH 12/13] =?UTF-8?q?=F0=9F=8F=8F=20alt-store=20index:=20incor?= =?UTF-8?q?porate=20review=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fdroidserver/common.py | 8 ++++++-- fdroidserver/index.py | 12 +++++++----- fdroidserver/update.py | 20 ++++++++++++++------ tests/index.TestCase | 2 +- 4 files changed, 28 insertions(+), 14 deletions(-) diff --git a/fdroidserver/common.py b/fdroidserver/common.py index 9e1a5340..c7c4c5c2 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -4155,7 +4155,6 @@ def is_repo_file(filename, for_gpg_signing=False): if isinstance(filename, str): filename = filename.encode('utf-8', errors="surrogateescape") ignore_files = [ - b'altstore-index.json', b'entry.jar', b'index-v1.jar', b'index.css', @@ -4166,7 +4165,12 @@ def is_repo_file(filename, for_gpg_signing=False): b'index_unsigned.jar', ] if not for_gpg_signing: - ignore_files += [b'entry.json', b'index-v1.json', b'index-v2.json'] + ignore_files += [ + b'altstore-index.json', + b'entry.json', + b'index-v1.json', + b'index-v2.json', + ] return ( os.path.isfile(filename) diff --git a/fdroidserver/index.py b/fdroidserver/index.py index 601cd9ce..778c4e75 100644 --- a/fdroidserver/index.py +++ b/fdroidserver/index.py @@ -130,7 +130,7 @@ def make(apps, apks, repodir, archive): apks, common.config, repodir, - indent=2 if common.options.pretty else None + pretty=common.options.pretty, ) @@ -1759,14 +1759,14 @@ def get_public_key_from_jar(jar): return public_key, public_key_fingerprint -def make_altstore(apps, apks, config, repodir, indent=None): - """ - Assemble altstore-index.json for iOS (.ipa) apps. +def make_altstore(apps, apks, config, repodir, pretty=False): + """Assemble altstore-index.json for iOS (.ipa) apps. builds index files based on: https://faq.altstore.io/distribute-your-apps/make-a-source https://faq.altstore.io/distribute-your-apps/updating-apps """ + indent = 2 if pretty else None # for now alt-store support is english only for lang in ['en']: @@ -1825,7 +1825,9 @@ def make_altstore(apps, apks, config, repodir, indent=None): # populate 'versions' for apk in apks: - if apk['packageName'] == packageName and apk.get('apkName', '').lower().endswith('.ipa'): + if apk['packageName'] == packageName and apk.get( + 'apkName', '' + ).lower().endswith('.ipa'): v = { "version": apk["versionName"], "date": apk["added"].isoformat(), diff --git a/fdroidserver/update.py b/fdroidserver/update.py index 6cd661a1..23e3d604 100644 --- a/fdroidserver/update.py +++ b/fdroidserver/update.py @@ -629,13 +629,20 @@ def parse_ipa(ipa_path, file_size, sha256): if info.filename.endswith("/embedded.mobileprovision"): print("parsing", info.filename) with ipa_zip.open(info) as mopro_file: - mopro_content_info = asn1crypto.cms.ContentInfo.load(mopro_file.read()) + mopro_content_info = asn1crypto.cms.ContentInfo.load( + mopro_file.read() + ) mopro_payload_info = mopro_content_info['content'] - mopro_payload = mopro_payload_info['encap_content_info']['content'].native + mopro_payload = mopro_payload_info['encap_content_info'][ + 'content' + ].native mopro = biplist.readPlistFromString(mopro_payload) # https://faq.altstore.io/distribute-your-apps/make-a-source#entitlements-array-of-strings for entitlement in mopro.get('Entitlements', {}).keys(): - if entitlement not in ["com.app.developer.team-identifier", 'application-identifier']: + if entitlement not in [ + "com.app.developer.team-identifier", + 'application-identifier' + ]: ipa["ipa_entitlements"].add(entitlement) return ipa @@ -666,8 +673,7 @@ def scan_repo_for_ipas(apkcache, repodir, knownapks): file_size = os.stat(ipa_path).st_size if file_size == 0: - raise FDroidException(_('{path} is zero size!') - .format(path=ipa_path)) + raise FDroidException(_('{path} is zero size!').format(path=ipa_path)) sha256 = common.sha256sum(ipa_path) ipa = apkcache.get(ipa_name, {}) @@ -1420,7 +1426,9 @@ def insert_localized_ios_app_metadata(apps_with_packages): for metadata_file in (lang_dir).iterdir(): key = FASTLANE_IOS_MAP.get(metadata_file.name) if key: - fdroidserver.update._set_localized_text_entry(app, locale, key, metadata_file) + fdroidserver.update._set_localized_text_entry( + app, locale, key, metadata_file + ) screenshots = fdroidserver.update.discover_ios_screenshots(fastlane_dir) fdroidserver.update.copy_ios_screenshots_to_repo(screenshots, package_name) diff --git a/tests/index.TestCase b/tests/index.TestCase index 49fda126..6e8ec89b 100755 --- a/tests/index.TestCase +++ b/tests/index.TestCase @@ -875,7 +875,7 @@ class AltstoreIndexTest(unittest.TestCase): apks, config, repodir, - 2, + True, ) with open(repodir / "altstore-index.json", 'r') as f: From 9716b5e1ab19967cf9071d7512fb5a73a46c3843 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 24 Apr 2024 10:29:50 +0200 Subject: [PATCH 13/13] index: manual black format --- fdroidserver/index.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/fdroidserver/index.py b/fdroidserver/index.py index 778c4e75..5a179487 100644 --- a/fdroidserver/index.py +++ b/fdroidserver/index.py @@ -1825,9 +1825,8 @@ def make_altstore(apps, apks, config, repodir, pretty=False): # populate 'versions' for apk in apks: - if apk['packageName'] == packageName and apk.get( - 'apkName', '' - ).lower().endswith('.ipa'): + last4 = apk.get('apkName', '').lower()[-4:] + if apk['packageName'] == packageName and last4 == '.ipa': v = { "version": apk["versionName"], "date": apk["added"].isoformat(),