; SPDX-License-Identifier: MIT ; ============================================================================= ; AppleAppSpec.rgr -- the app, as everything but the code. ; ============================================================================= ; ; An .app bundle is a directory with an executable in it and a property list ; that explains the executable to the system. Xcode builds that plist from a ; project file, a target, a scheme and a build settings sheet; a command line ; build has to say it in one place, and this is that place. ; ; The plist writer is here too, because the keys are not free-form: an iOS app ; that omits `CFBundleExecutable` does not launch, one that omits ; `UIDeviceFamily` is an iPhone app on an iPad, and one that omits ; `UILaunchScreen` is letterboxed into a phone-sized box on every iPad no ; matter what its families say. Those three are the ones a hand-built bundle ; gets wrong, so they are written from the spec rather than left to a caller. ; ============================================================================= class AppleAppSpec { ; The bundle and executable name. No spaces -- this is a file name. def name:string "RangerApp" ; What the home screen shows under the icon. def displayName:string "Ranger App" ; Reverse DNS, and the identity the simulator installs and launches by. def bundleId:string "fi.ranger.app" def version:string "1.0" def build:string "1" ; Every Swift file that goes into the executable, in no particular order. ; For a Ranger app this is the generated .swift plus the host sources. def sources:[string] ; Files and directories copied into the bundle as they are. A stylesheet, a ; font, an icon. def resources:[string] ; Anything extra for swiftc: -O, -warnings-as-errors, a framework path. def swiftFlags:[string] ; Frameworks to link that swiftc does not pull in on its own. def frameworks:[string] ; Extra Info.plist entries, as already-formatted XML fragments. The escape ; hatch for a key this class does not know about. def extraPlist:[string] ; The .mobileprovision for a DEVICE build. Ignored for the simulator, which ; needs no profile at all. When set, it is copied into the bundle as ; `embedded.mobileprovision` and the entitlements it grants are read out of ; it and handed to codesign. def provisioningProfile:string "" ; Portrait plus both landscapes, which is what a page that reflows wants. ; An empty list leaves the key out and lets the system decide. def orientations:[string] Constructor () { orientations = ([] _:string ( "UIInterfaceOrientationPortrait" "UIInterfaceOrientationLandscapeLeft" "UIInterfaceOrientationLandscapeRight" )) } fn addSource:void (path:string) { push sources path } fn addResource:void (path:string) { push resources path } fn addFramework:void (name:string) { push frameworks name } ; The bundle directory name. `.app` is not decoration -- the simulator ; installs a directory by that extension and nothing else. fn bundleName:string () { return (name + ".app") } } ; The property list, written out. XML rather than JSON or binary because it is ; the format every Apple tool reads without conversion, and because a build that ; goes wrong is one a person has to be able to read. class AppleInfoPlist { ; Escape the five characters an XML text node cannot hold. A display name ; with an ampersand in it is not exotic, and an unescaped one makes the ; plist unparseable rather than wrong -- the app simply will not install. sfn escape:string (s:string) { def out:string s out = (join (strsplit out "&") "&") out = (join (strsplit out "<") "<") out = (join (strsplit out ">") ">") out = (join (strsplit out "\"") """) return out } sfn stringKey:string (key:string value:string) { return ("\t" + (AppleInfoPlist.escape(key)) + "\n\t" + (AppleInfoPlist.escape(value)) + "") } sfn boolKey:string (key:string value:boolean) { def word:string "" if value { word = "" } return ("\t" + (AppleInfoPlist.escape(key)) + "\n\t" + word) } sfn intArrayKey:string (key:string values:[int]) { def rows:[string] push rows ("\t" + (AppleInfoPlist.escape(key)) + "") push rows "\t" for values v:int i { push rows ("\t\t" + v + "") } push rows "\t" return (join rows "\n") } sfn stringArrayKey:string (key:string values:[string]) { def rows:[string] push rows ("\t" + (AppleInfoPlist.escape(key)) + "") push rows "\t" for values v:string i { push rows ("\t\t" + (AppleInfoPlist.escape(v)) + "") } push rows "\t" return (join rows "\n") } ; The whole plist for one app on one target. sfn build:string (spec:AppleAppSpec target:AppleTarget) { def rows:[string] push rows "" push rows "" push rows "" push rows "" push rows (AppleInfoPlist.stringKey("CFBundleDevelopmentRegion" "en")) ; The one key with no default worth guessing: without it the system has ; a directory and no idea which file in it to run. push rows (AppleInfoPlist.stringKey("CFBundleExecutable" spec.name)) push rows (AppleInfoPlist.stringKey("CFBundleIdentifier" spec.bundleId)) push rows (AppleInfoPlist.stringKey("CFBundleInfoDictionaryVersion" "6.0")) push rows (AppleInfoPlist.stringKey("CFBundleName" spec.name)) push rows (AppleInfoPlist.stringKey("CFBundleDisplayName" spec.displayName)) push rows (AppleInfoPlist.stringKey("CFBundlePackageType" "APPL")) push rows (AppleInfoPlist.stringKey("CFBundleShortVersionString" spec.version)) push rows (AppleInfoPlist.stringKey("CFBundleVersion" spec.build)) def platformName:string "iPhoneOS" if (target.isWatch()) { platformName = "WatchOS" if target.isSimulator { platformName = "WatchSimulator" } } { if target.isSimulator { platformName = "iPhoneSimulator" } } def platforms:[string] push platforms platformName push rows (AppleInfoPlist.stringArrayKey("CFBundleSupportedPlatforms" platforms)) if (target.isWatch()) { ; A standalone watch app -- no paired iPhone app to be an appendage ; of, which is what watchOS 6 and later allow and what makes a watch ; build from the command line reasonable at all. WKApplication is ; what watchOS 7 and later read, WKWatchOnly says there is no ; companion, and WKRunsIndependentlyOfCompanionApp is what the ; watchOS 6 generation read for the same thing. All three, because ; which one is consulted depends on the OS and none of them costs ; anything. push rows (AppleInfoPlist.boolKey("WKApplication" true)) push rows (AppleInfoPlist.boolKey("WKWatchOnly" true)) push rows (AppleInfoPlist.boolKey("WKRunsIndependentlyOfCompanionApp" true)) push rows (AppleInfoPlist.stringKey("MinimumOSVersion" target.minVersion)) } { push rows (AppleInfoPlist.stringKey("MinimumOSVersion" target.minVersion)) push rows (AppleInfoPlist.intArrayKey("UIDeviceFamily" target.families)) if ((array_length spec.orientations) > 0) { push rows (AppleInfoPlist.stringArrayKey("UISupportedInterfaceOrientations" spec.orientations)) push rows (AppleInfoPlist.stringArrayKey("UISupportedInterfaceOrientations~ipad" spec.orientations)) } ; An empty UILaunchScreen dictionary is the modern way to say "this ; app has no launch storyboard and is still full screen". Leave it ; out and iOS runs the app letterboxed in a phone-sized window on ; every iPad, which looks exactly like a layout bug. push rows "\tUILaunchScreen" push rows "\t" push rows (AppleInfoPlist.boolKey("UIRequiresFullScreen" false)) } for spec.extraPlist row:string i { push rows row } push rows "" push rows "" push rows "" return (join rows "\n") } }