1import crypto from 'crypto'; 2import { Builder, Parser } from 'xml2js'; 3 4const debug = require('debug')( 5 'expo:prebuild-config:expo-splash-screen:ios:InterfaceBuilder' 6) as typeof console.log; 7 8export type IBBoolean = 'YES' | 'NO' | boolean; 9 10export type IBItem< 11 H extends Record<string, any>, 12 B extends Record<string, any[]> = { [key: string]: any }, 13> = { 14 $: H; 15} & B; 16 17export type Rect = { 18 key: string; 19 x: number; 20 y: number; 21 width: number; 22 height: number; 23}; 24 25export type IBRect = IBItem<Rect>; 26 27export type IBAutoresizingMask = IBItem<{ 28 /** @example `autoresizingMask` */ 29 key: string; 30 flexibleMaxX: IBBoolean; 31 flexibleMaxY: IBBoolean; 32}>; 33 34/** @example `<color key="textColor" systemColor="linkColor"/>` */ 35export type IBColor = IBItem< 36 { 37 /** @example `textColor` */ 38 key: string; 39 } & ( 40 | /** Custom color */ 41 { 42 /** @example `0.86584504117670746` */ 43 red: number; 44 /** @example `0.26445041990630447` */ 45 green: number; 46 /** @example `0.3248577810203549` */ 47 blue: number; 48 /** @example `1` */ 49 alpha: number; 50 colorSpace: 'custom' | string; 51 customColorSpace: 'displayP3' | 'sRGB' | string; 52 } 53 /** Built-in color */ 54 | { 55 systemColor: 'linkColor' | string; 56 } 57 ) 58>; 59 60export type IBFontDescription = IBItem<{ 61 /** @example `fontDescription` */ 62 key: string; 63 /** Font size */ 64 pointSize: number; 65 66 /** Custom font */ 67 name?: 'HelveticaNeue' | string; 68 family?: 'Helvetica Neue' | string; 69 70 /** Built-in font */ 71 type?: 'system' | 'boldSystem' | 'UICTFontTextStyleCallout' | 'UICTFontTextStyleBody' | string; 72}>; 73 74export type ImageContentMode = 'scaleAspectFit' | 'scaleAspectFill'; 75 76export type ConstraintAttribute = 'top' | 'bottom' | 'trailing' | 'leading'; 77 78export type IBImageView = IBItem< 79 { 80 id: string; 81 userLabel: string; 82 image: string; 83 clipsSubviews?: IBBoolean; 84 userInteractionEnabled: IBBoolean; 85 contentMode: IBContentMode; 86 horizontalHuggingPriority: number; 87 verticalHuggingPriority: number; 88 insetsLayoutMarginsFromSafeArea?: IBBoolean; 89 translatesAutoresizingMaskIntoConstraints?: IBBoolean; 90 }, 91 { 92 rect: IBRect[]; 93 } 94>; 95 96export type IBLabel = IBItem< 97 { 98 id: string; 99 /** The main value. */ 100 text: string; 101 102 opaque: IBBoolean; 103 fixedFrame: IBBoolean; 104 textAlignment?: IBTextAlignment; 105 lineBreakMode: 106 | 'clip' 107 | 'characterWrap' 108 | 'wordWrap' 109 | 'headTruncation' 110 | 'middleTruncation' 111 | 'tailTruncation'; 112 baselineAdjustment?: 'none' | 'alignBaselines'; 113 adjustsFontSizeToFit: IBBoolean; 114 userInteractionEnabled: IBBoolean; 115 contentMode: IBContentMode; 116 horizontalHuggingPriority: number; 117 verticalHuggingPriority: number; 118 translatesAutoresizingMaskIntoConstraints?: IBBoolean; 119 }, 120 { 121 /** @example `<rect key="frame" x="175" y="670" width="35" height="17"/>` */ 122 rect: IBRect[]; 123 /** @example `<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>` */ 124 autoresizingMask?: IBAutoresizingMask[]; 125 /** @example `<fontDescription key="fontDescription" type="system" pointSize="19"/>` */ 126 fontDescription?: IBFontDescription[]; 127 /** @example `<color key="textColor" red="0.0" green="0.0" blue="0.0" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>` */ 128 color?: IBColor[]; 129 nil?: IBItem<{ 130 /** @example `textColor` `highlightedColor` */ 131 key: string; 132 }>[]; 133 } 134>; 135 136export type IBTextAlignment = 'left' | 'center' | 'right' | 'justified' | 'natural'; 137 138export type IBContentMode = string | 'left' | 'scaleAspectFill'; 139 140export type IBConstraint = IBItem<{ 141 firstItem: string; 142 firstAttribute: ConstraintAttribute; 143 secondItem: string; 144 secondAttribute: ConstraintAttribute; 145 constant?: number; 146 id: string; 147}>; 148 149export type IBViewController = IBItem< 150 { 151 id: string; 152 placeholderIdentifier?: string; 153 userLabel: string; 154 sceneMemberID: string; 155 }, 156 { 157 view: IBItem< 158 { 159 id: string; 160 key: string; 161 userInteractionEnabled: IBBoolean; 162 contentMode: string | 'scaleToFill'; 163 insetsLayoutMarginsFromSafeArea: IBBoolean; 164 userLabel: string; 165 }, 166 { 167 rect: IBRect[]; 168 autoresizingMask: IBItem<{ 169 key: string; 170 flexibleMaxX: IBBoolean; 171 flexibleMaxY: IBBoolean; 172 }>[]; 173 174 subviews: IBItem< 175 object, 176 { 177 imageView: IBImageView[]; 178 label: IBLabel[]; 179 } 180 >[]; 181 color: IBItem<{ 182 key: string | 'backgroundColor'; 183 systemColor: string | 'systemBackgroundColor'; 184 }>[]; 185 constraints: IBItem< 186 object, 187 { 188 constraint: IBConstraint[]; 189 } 190 >[]; 191 viewLayoutGuide: IBItem<{ 192 id: string; 193 key: string | 'safeArea'; 194 }>[]; 195 } 196 >[]; 197 } 198>; 199 200export type IBPoint = IBItem<{ 201 key: string | 'canvasLocation'; 202 x: number; 203 y: number; 204}>; 205 206export type IBScene = IBItem< 207 { sceneID: string }, 208 { 209 objects: { 210 viewController: IBViewController[]; 211 placeholder: IBItem<{ 212 id: string; 213 placeholderIdentifier?: string; 214 userLabel: string; 215 sceneMemberID: string; 216 }>[]; 217 }[]; 218 point: IBPoint[]; 219 } 220>; 221 222export type IBResourceImage = IBItem<{ 223 name: string; 224 width: number; 225 height: number; 226}>; 227 228export type IBDevice = IBItem<{ 229 id: string; 230 orientation: string | 'portrait'; 231 appearance: string | 'light'; 232}>; 233 234export type IBSplashScreenDocument = { 235 document: IBItem< 236 { 237 type: 'com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB' | string; 238 version: '3.0' | string; 239 toolsVersion: number; 240 targetRuntime: 'iOS.CocoaTouch' | string; 241 propertyAccessControl: 'none' | string; 242 useAutolayout: IBBoolean; 243 launchScreen: IBBoolean; 244 useTraitCollections: IBBoolean; 245 useSafeAreas: IBBoolean; 246 colorMatched: IBBoolean; 247 initialViewController: string; 248 }, 249 { 250 device: IBDevice[]; 251 dependencies: unknown[]; 252 scenes: { 253 scene: IBScene[]; 254 }[]; 255 resources: { 256 image: IBResourceImage[]; 257 }[]; 258 } 259 >; 260}; 261 262export function createConstraint( 263 [firstItem, firstAttribute]: [string, ConstraintAttribute], 264 [secondItem, secondAttribute]: [string, ConstraintAttribute], 265 constant?: number 266): IBConstraint { 267 return { 268 $: { 269 firstItem, 270 firstAttribute, 271 secondItem, 272 secondAttribute, 273 constant, 274 // Prevent updating between runs 275 id: createConstraintId(firstItem, firstAttribute, secondItem, secondAttribute), 276 }, 277 }; 278} 279 280export function createConstraintId(...attributes: string[]) { 281 return crypto.createHash('sha1').update(attributes.join('-')).digest('hex'); 282} 283 284const IMAGE_ID = 'EXPO-SplashScreen'; 285const CONTAINER_ID = 'EXPO-ContainerView'; 286 287export function removeImageFromSplashScreen( 288 xml: IBSplashScreenDocument, 289 { imageName }: { imageName: string } 290) { 291 const mainView = xml.document.scenes[0].scene[0].objects[0].viewController[0].view[0]; 292 293 debug(`Remove all splash screen image elements`); 294 295 removeExisting(mainView.subviews[0].imageView, IMAGE_ID); 296 297 // Add Constraints 298 getAbsoluteConstraints(IMAGE_ID, CONTAINER_ID).forEach((constraint) => { 299 // <constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="2VS-Uz-0LU"/> 300 const constrainsArray = mainView.constraints[0].constraint; 301 removeExisting(constrainsArray, constraint); 302 }); 303 304 // Add resource 305 const imageSection = xml.document.resources[0].image; 306 307 const existingImageIndex = imageSection.findIndex((image) => image.$.name === imageName); 308 if (existingImageIndex > -1) { 309 imageSection.splice(existingImageIndex, 1); 310 } 311 return xml; 312} 313 314function getAbsoluteConstraints(childId: string, parentId: string) { 315 return [ 316 createConstraint([childId, 'top'], [parentId, 'top']), 317 createConstraint([childId, 'leading'], [parentId, 'leading']), 318 createConstraint([childId, 'trailing'], [parentId, 'trailing']), 319 createConstraint([childId, 'bottom'], [parentId, 'bottom']), 320 ]; 321} 322 323export function applyImageToSplashScreenXML( 324 xml: IBSplashScreenDocument, 325 { 326 imageName, 327 contentMode, 328 }: { 329 imageName: string; 330 contentMode: ImageContentMode; 331 } 332): IBSplashScreenDocument { 333 const width = 414; 334 const height = 736; 335 336 const imageView: IBImageView = { 337 $: { 338 id: IMAGE_ID, 339 userLabel: imageName, 340 image: imageName, 341 contentMode, 342 horizontalHuggingPriority: 251, 343 verticalHuggingPriority: 251, 344 clipsSubviews: true, 345 userInteractionEnabled: false, 346 translatesAutoresizingMaskIntoConstraints: false, 347 }, 348 rect: [ 349 { 350 $: { 351 key: 'frame', 352 x: 0.0, 353 y: 0.0, 354 width, 355 height, 356 }, 357 }, 358 ], 359 }; 360 361 const mainView = xml.document.scenes[0].scene[0].objects[0].viewController[0].view[0]; 362 363 // Add ImageView 364 ensureUniquePush(mainView.subviews[0].imageView, imageView); 365 366 // Add Constraints 367 getAbsoluteConstraints(IMAGE_ID, CONTAINER_ID).forEach((constraint) => { 368 // <constraint firstItem="EXPO-SplashScreen" firstAttribute="top" secondItem="EXPO-ContainerView" secondAttribute="top" id="2VS-Uz-0LU"/> 369 const constrainsArray = mainView.constraints[0].constraint; 370 ensureUniquePush(constrainsArray, constraint); 371 }); 372 373 // Add resource 374 const imageSection = xml.document.resources[0].image; 375 376 const existingImageIndex = imageSection.findIndex((image) => image.$.name === imageName); 377 if (existingImageIndex > -1) { 378 debug(`Removing existing IB image asset at index ${existingImageIndex}`); 379 imageSection.splice(existingImageIndex, 1); 380 } 381 imageSection.push({ 382 // <image name="SplashScreen" width="414" height="736"/> 383 $: { 384 name: imageName, 385 width, 386 height, 387 }, 388 }); 389 390 return xml; 391} 392 393/** 394 * IB does not allow two items to have the same ID. 395 * This method will add an item by first removing any existing item with the same `$.id`. 396 */ 397export function ensureUniquePush<TItem extends { $: { id: string } }>(array: TItem[], item: TItem) { 398 if (!array) return array; 399 removeExisting(array, item); 400 array.push(item); 401 return array; 402} 403 404export function removeExisting<TItem extends { $: { id: string } }>( 405 array: TItem[], 406 item: TItem | string 407) { 408 const id = typeof item === 'string' ? item : item.$?.id; 409 const existingItem = array?.findIndex((existingItem) => existingItem.$.id === id); 410 if (existingItem > -1) { 411 debug(`Removing existing IB item with id ${id}, from: %O`, array); 412 array.splice(existingItem, 1); 413 } 414 return array; 415} 416 417// Attempt to copy Xcode formatting. 418export function toString(xml: any): string { 419 const builder = new Builder({ 420 // @ts-expect-error: untyped 421 preserveChildrenOrder: true, 422 xmldec: { 423 version: '1.0', 424 encoding: 'UTF-8', 425 }, 426 renderOpts: { 427 pretty: true, 428 indent: ' ', 429 }, 430 }); 431 return builder.buildObject(xml); 432} 433 434/** Parse string contents into an object. */ 435export function toObjectAsync(contents: string) { 436 return new Parser().parseStringPromise(contents); 437} 438