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