Files
ios/tools/generate_project.py
T

177 lines
15 KiB
Python

"""Generate a dependency-free Xcode project. Run with Python 3 from any directory."""
import hashlib
import json
import pathlib
import plistlib
ROOT = pathlib.Path(__file__).resolve().parents[1]
objects = {}
def uid(name):
return hashlib.sha256(name.encode()).hexdigest()[:24].upper()
def add(key_name, isa, **fields):
key = uid(key_name)
objects[key] = dict(isa=isa, **fields)
return key
def configurations(name, settings):
configs = []
for mode in ("Debug", "Release"):
values = dict(settings)
values.update(SWIFT_OPTIMIZATION_LEVEL="-Onone" if mode == "Debug" else "-O")
if mode == "Debug":
values.update(ENABLE_TESTABILITY="YES", SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG")
if name == "MusicBridgeWidgets":
values["SWIFT_ACTIVE_COMPILATION_CONDITIONS"] = values.get("SWIFT_ACTIVE_COMPILATION_CONDITIONS", "") + " MUSICBRIDGE_WIDGET"
configs.append(add(name + mode, "XCBuildConfiguration", name=mode, buildSettings=values))
return add(name + "configs", "XCConfigurationList", buildConfigurations=configs,
defaultConfigurationIsVisible=0, defaultConfigurationName="Release")
def source_group(folder):
refs, builds = [], []
for path in sorted((ROOT / folder).glob("*.swift")):
ref = add(path.relative_to(ROOT).as_posix(), "PBXFileReference", lastKnownFileType="sourcecode.swift", path=path.name, sourceTree="<group>")
refs.append(ref)
builds.append(add("build/" + path.relative_to(ROOT).as_posix(), "PBXBuildFile", fileRef=ref))
return add(folder, "PBXGroup", path=folder, sourceTree="<group>", children=refs), builds
app_group, app_sources = source_group("MusicBridge")
remote_shared_group, remote_shared_sources = source_group("RemoteShared")
remote_group, remote_sources = source_group("MusicBridgeRemote")
remote_sources += [add("remote-shared/" + str(index), "PBXBuildFile", fileRef=objects[build]["fileRef"])
for index, build in enumerate(remote_shared_sources)]
for filename in ("Models.swift", "BridgeClient.swift"):
remote_sources.append(add("remote-core/" + filename, "PBXBuildFile", fileRef=uid("MusicBridge/" + filename)))
app_sources += remote_shared_sources
shared_group, shared_sources = source_group("Shared")
widget_group, widget_sources = source_group("MusicBridgeWidgets")
app_sources += shared_sources
widget_sources += [add("widget-shared/" + str(index), "PBXBuildFile", fileRef=objects[build]["fileRef"])
for index, build in enumerate(shared_sources)]
assets = add("assets", "PBXFileReference", lastKnownFileType="folder.assetcatalog", path="Assets.xcassets", sourceTree="<group>")
objects[app_group]["children"].append(assets)
assets_build = add("assets-build", "PBXBuildFile", fileRef=assets)
test_group, test_sources = source_group("MusicBridgeTests")
fixture = add("fixture", "PBXFileReference", lastKnownFileType="text.json", path="state-v1.json", sourceTree="<group>")
objects[test_group]["children"].append(fixture)
fixture_build = add("fixture-build", "PBXBuildFile", fileRef=fixture)
tls_fixture = add("tls-fixture", "PBXFileReference", lastKnownFileType="text.json", path="tls-fixture.json", sourceTree="<group>")
objects[test_group]["children"].append(tls_fixture)
tls_fixture_build = add("tls-fixture-build", "PBXBuildFile", fileRef=tls_fixture)
products = []
targets = []
for name, sources, is_test in [("MusicBridge", app_sources, False), ("MusicBridgeTests", test_sources, True), ("MusicBridgeWidgets", widget_sources, False), ("MusicBridgeRemote", remote_sources, False)]:
is_widget = name == "MusicBridgeWidgets"
is_remote = name == "MusicBridgeRemote"
is_extension = is_widget or is_remote
product = add(name + "product", "PBXFileReference", explicitFileType="wrapper.extensionkit-extension" if is_remote else "wrapper.app-extension" if is_extension else "wrapper.cfbundle" if is_test else "wrapper.application",
includeInIndex=0, path=name + (".appex" if is_extension else ".xctest" if is_test else ".app"), sourceTree="BUILT_PRODUCTS_DIR")
products.append(product)
phases = [add(name + "sources", "PBXSourcesBuildPhase", buildActionMask=2147483647, files=sources, runOnlyForDeploymentPostprocessing=0),
add(name + "frameworks", "PBXFrameworksBuildPhase", buildActionMask=2147483647, files=[], runOnlyForDeploymentPostprocessing=0),
add(name + "resources", "PBXResourcesBuildPhase", buildActionMask=2147483647, files=[] if is_extension else [fixture_build, tls_fixture_build] if is_test else [assets_build], runOnlyForDeploymentPostprocessing=0)]
settings = dict(PRODUCT_NAME="$(TARGET_NAME)", PRODUCT_BUNDLE_IDENTIFIER="ru.yukinoki.musicbridge" + (".remote" if is_remote else ".widgets" if is_widget else ".tests" if is_test else ""),
SWIFT_VERSION="5.0", IPHONEOS_DEPLOYMENT_TARGET="27.0" if is_remote else "17.0", SDKROOT="iphoneos",
SUPPORTED_PLATFORMS="iphoneos iphonesimulator", TARGETED_DEVICE_FAMILY="1", CODE_SIGN_STYLE="Automatic",
GENERATE_INFOPLIST_FILE="YES" if is_test else "NO")
deps = []
if is_test:
settings.update(TEST_HOST="$(BUILT_PRODUCTS_DIR)/MusicBridge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MusicBridge", BUNDLE_LOADER="$(TEST_HOST)")
proxy = add("test-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1, remoteGlobalIDString=uid("MusicBridgetarget"), remoteInfo="MusicBridge")
deps = [add("test-dependency", "PBXTargetDependency", target=uid("MusicBridgetarget"), targetProxy=proxy)]
elif is_extension:
settings.update(INFOPLIST_FILE=name + "/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0",
APPLICATION_EXTENSION_API_ONLY="YES", SKIP_INSTALL="YES",
LD_RUNPATH_SEARCH_PATHS="$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks")
if is_remote:
settings.update(CODE_SIGN_ENTITLEMENTS="MusicBridgeRemote/Remote.entitlements")
else:
settings.update(INFOPLIST_FILE="MusicBridge/Info.plist", CURRENT_PROJECT_VERSION="8", MARKETING_VERSION="0.7.0", ASSETCATALOG_COMPILER_APPICON_NAME="AppIcon", CODE_SIGN_ENTITLEMENTS="MusicBridge/App.entitlements")
embed = add("embed-widget", "PBXBuildFile", fileRef=uid("MusicBridgeWidgetsproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
embed_remote = add("embed-remote", "PBXBuildFile", fileRef=uid("MusicBridgeRemoteproduct"), settings=dict(ATTRIBUTES=["RemoveHeadersOnCopy"]))
phases.append(add("embed-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="", dstSubfolderSpec=13, files=[embed], name="Embed App Extensions", runOnlyForDeploymentPostprocessing=0))
phases.append(add("embed-remote-extensions", "PBXCopyFilesBuildPhase", buildActionMask=2147483647,
dstPath="$(EXTENSIONS_FOLDER_PATH)", dstSubfolderSpec=16, files=[embed_remote], name="Embed ExtensionKit Extensions", runOnlyForDeploymentPostprocessing=0))
proxy = add("widget-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeWidgetstarget"), remoteInfo="MusicBridgeWidgets")
deps = [add("widget-dependency", "PBXTargetDependency", target=uid("MusicBridgeWidgetstarget"), targetProxy=proxy)]
remote_proxy = add("remote-proxy", "PBXContainerItemProxy", containerPortal=uid("project"), proxyType=1,
remoteGlobalIDString=uid("MusicBridgeRemotetarget"), remoteInfo="MusicBridgeRemote")
deps.append(add("remote-dependency", "PBXTargetDependency", target=uid("MusicBridgeRemotetarget"), targetProxy=remote_proxy))
targets.append(add(name + "target", "PBXNativeTarget", name=name, productName=name, productReference=product,
productType="com.apple.product-type.extensionkit-extension" if is_remote else "com.apple.product-type.app-extension" if is_widget else "com.apple.product-type.bundle.unit-test" if is_test else "com.apple.product-type.application",
buildConfigurationList=configurations(name, settings), buildPhases=phases, buildRules=[], dependencies=deps))
product_group = add("products", "PBXGroup", name="Products", sourceTree="<group>", children=products)
main_group = add("main", "PBXGroup", sourceTree="<group>", children=[app_group, shared_group, widget_group, remote_shared_group, remote_group, test_group, product_group])
project = add("project", "PBXProject", attributes=dict(LastUpgradeCheck="1600", BuildIndependentTargetsInParallel="YES"),
buildConfigurationList=configurations("project", dict(CLANG_ENABLE_MODULES="YES", CLANG_ENABLE_OBJC_ARC="YES")),
compatibilityVersion="Xcode 14.0", developmentRegion="ru", knownRegions=["ru", "en", "Base"],
mainGroup=main_group, productRefGroup=product_group, projectDirPath="", projectRoot="", targets=targets)
def encode(value, indent=0):
if isinstance(value, dict):
return "{\n" + "".join("\t" * (indent + 1) + json.dumps(k) + " = " + encode(v, indent + 1) + ";\n" for k, v in value.items()) + "\t" * indent + "}"
if isinstance(value, list):
return "(" + ", ".join(encode(v, indent) for v in value) + ("," if value else "") + ")"
return json.dumps(value, ensure_ascii=True)
project_dir = ROOT / "MusicBridge.xcodeproj"
project_dir.mkdir(exist_ok=True)
(project_dir / "project.pbxproj").write_bytes(("// !$*UTF8*$!\n" + encode(dict(archiveVersion=1, classes={}, objectVersion=56, objects=objects, rootObject=project)) + "\n").encode("utf-8"))
scheme_dir = project_dir / "xcshareddata" / "xcschemes"
scheme_dir.mkdir(parents=True, exist_ok=True)
def reference(target, product):
return f'<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="{uid(target + "target")}" BuildableName="{product}" BlueprintName="{target}" ReferencedContainer="container:MusicBridge.xcodeproj"/>'
app_ref = reference("MusicBridge", "MusicBridge.app")
test_ref = reference("MusicBridgeTests", "MusicBridgeTests.xctest")
(scheme_dir / "MusicBridge.xcscheme").write_bytes(f'''<?xml version="1.0" encoding="UTF-8"?>
<Scheme LastUpgradeVersion="1600" version="1.3">
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries>
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">{app_ref}</BuildActionEntry>
</BuildActionEntries></BuildAction>
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"><Testables><TestableReference skipped="NO">{test_ref}</TestableReference></Testables></TestAction>
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES"><BuildableProductRunnable runnableDebuggingMode="0">{app_ref}</BuildableProductRunnable></LaunchAction>
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"><BuildableProductRunnable runnableDebuggingMode="0">{app_ref}</BuildableProductRunnable></ProfileAction>
<AnalyzeAction buildConfiguration="Debug"/><ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
</Scheme>'''.encode("utf-8"))
info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge", CFBundleExecutable="$(EXECUTABLE_NAME)",
CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)", CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)",
CFBundlePackageType="APPL", CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
LSRequiresIPhoneOS=True, UILaunchScreen={}, NSSupportsLiveActivities=True,
UISupportedInterfaceOrientations=["UIInterfaceOrientationPortrait"],
NSLocalNetworkUsageDescription="MusicBridge подключается к вашему ПК, чтобы показывать музыку и управлять воспроизведением.",
NSCameraUsageDescription="Камера сканирует QR в MusicBridge на ПК для безопасного сопряжения.",
NSBonjourServices=["_musicbridge._tcp"],
CFBundleURLTypes=[dict(CFBundleURLName="ru.yukinoki.musicbridge.pair", CFBundleURLSchemes=["musicbridge"])],
# iOS 17+ requires explicit IP exceptions for custom self-signed trust.
# BridgeClient still requires HTTPS, an exact SHA-256 pin and valid dates.
# Never disable ATS globally or include public IP ranges.
NSAppTransportSecurity=dict(NSAllowsLocalNetworking=True, NSExceptionDomains={
network: dict(NSExceptionAllowsInsecureHTTPLoads=True)
for network in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
}))
(ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False))
widget_info = dict(CFBundleDevelopmentRegion="ru", CFBundleDisplayName="MusicBridge",
CFBundleExecutable="$(EXECUTABLE_NAME)", CFBundleIdentifier="$(PRODUCT_BUNDLE_IDENTIFIER)",
CFBundleInfoDictionaryVersion="6.0", CFBundleName="$(PRODUCT_NAME)", CFBundlePackageType="XPC!",
CFBundleShortVersionString="$(MARKETING_VERSION)", CFBundleVersion="$(CURRENT_PROJECT_VERSION)",
NSExtension=dict(NSExtensionPointIdentifier="com.apple.widgetkit-extension"))
(ROOT / "MusicBridgeWidgets" / "Info.plist").write_bytes(plistlib.dumps(widget_info, sort_keys=False))
shared_group_name = "$(AppIdentifierPrefix)ru.yukinoki.musicbridge.remote-control"
info["MusicBridgeSharedKeychainGroup"] = "ru.yukinoki.musicbridge.remote-control"
(ROOT / "MusicBridge" / "Info.plist").write_bytes(plistlib.dumps(info, sort_keys=False))
remote_info = {k: v for k, v in widget_info.items() if k != "NSExtension"}
remote_info.update(EXAppExtensionAttributes=dict(EXExtensionPointIdentifier="com.apple.nowplaying.remote-media"),
MusicBridgeSharedKeychainGroup=info["MusicBridgeSharedKeychainGroup"],
NSLocalNetworkUsageDescription=info["NSLocalNetworkUsageDescription"],
NSAppTransportSecurity=info["NSAppTransportSecurity"])
(ROOT / "MusicBridgeRemote" / "Info.plist").write_bytes(plistlib.dumps(remote_info, sort_keys=False))
for folder, filename in (("MusicBridge", "App.entitlements"), ("MusicBridgeRemote", "Remote.entitlements")):
groups = ["$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)", shared_group_name]
(ROOT / folder / filename).write_bytes(plistlib.dumps({"keychain-access-groups": groups}))
print("Generated Xcode project, shared scheme and Info.plist.")