@craft-native/ios 0.0.122 → 0.0.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -378,6 +378,46 @@ function renderAssetCatalog(output, config) {
378
378
  info: { author: "xcode", version: 1 }
379
379
  }, null, 2)}
380
380
  `);
381
+ renderLaunchLogo(catalog, config);
382
+ }
383
+ var LAUNCH_LOGO = "LaunchLogo";
384
+ function renderLaunchLogo(catalog, config) {
385
+ const imageset = join(catalog, `${LAUNCH_LOGO}.imageset`);
386
+ rmSync(imageset, { recursive: true, force: true });
387
+ if (!config.splashImagePath)
388
+ return;
389
+ mkdirSync(imageset, { recursive: true });
390
+ const images = [];
391
+ let vector = false;
392
+ for (const [source, dark] of [[config.splashImagePath, false], [config.splashImagePathDark, true]]) {
393
+ if (!source)
394
+ continue;
395
+ if (!existsSync(source))
396
+ throw new Error(`Splash image not found: ${source}`);
397
+ const extension = source.split(".").pop().toLowerCase();
398
+ if (extension === "svg" || extension === "pdf")
399
+ vector = true;
400
+ const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}.${extension}`;
401
+ cpSync(source, join(imageset, filename));
402
+ images.push({
403
+ ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
404
+ filename,
405
+ idiom: "universal"
406
+ });
407
+ }
408
+ writeFileSync(join(imageset, "Contents.json"), `${JSON.stringify({
409
+ images,
410
+ info: { author: "xcode", version: 1 },
411
+ ...vector ? { properties: { "preserves-vector-representation": true } } : {}
412
+ }, null, 2)}
413
+ `);
414
+ }
415
+ function renderLaunchScreen(config) {
416
+ const image = config.splashImagePath ? `
417
+ <key>UIImageName</key>
418
+ <string>${LAUNCH_LOGO}</string>` : "";
419
+ return ` <key>UIColorName</key>
420
+ <string>LaunchBackground</string>${image}`;
381
421
  }
382
422
  function syncWebAssets(source, output) {
383
423
  const sourcePath = resolve(source);
@@ -494,7 +534,7 @@ async function init(options) {
494
534
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
495
535
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
496
536
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
497
- const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
537
+ const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
498
538
  <true/>
499
539
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
500
540
  <true/>` : "");
package/dist/index.d.ts CHANGED
@@ -85,6 +85,16 @@ export interface CraftConfig {
85
85
  associatedDomains?: string[];
86
86
  appGroups?: string[];
87
87
  appIconPath?: string;
88
+ /**
89
+ * The logo the launch screen shows on `backgroundColor`, and that stays on
90
+ * screen until the page says it is ready (`splash.hide()`), so the app goes
91
+ * from launch to content without a blank web view in between. PNG, PDF or
92
+ * SVG, drawn at its own size in points: give an SVG the width and height it
93
+ * should have on screen.
94
+ */
95
+ splashImagePath?: string;
96
+ /** The splash logo in Dark Mode, on `backgroundColorDark`. The light one if unset. */
97
+ splashImagePathDark?: string;
88
98
  /**
89
99
  * Whether the app uses encryption beyond the exempt kinds (HTTPS, the
90
100
  * system's own). Written as `ITSAppUsesNonExemptEncryption`, so App Store
@@ -205,6 +215,10 @@ export declare function renderPrivacyManifest(config: CraftConfig): string;
205
215
  * (grey + alpha) or 6 (RGB + alpha), or a tRNS chunk before the image data.
206
216
  */
207
217
  export declare function pngHasAlpha(bytes: Uint8Array): boolean;
218
+ /** The asset the launch screen and the splash both draw, so one hands over to the other unseen. */
219
+ export declare const LAUNCH_LOGO = "LaunchLogo";
220
+ /** The launch screen: the background colour, and the splash logo on it when there is one. */
221
+ export declare function renderLaunchScreen(config: Pick<CraftConfig, 'splashImagePath'>): string;
208
222
  /** Replace the bundled web application atomically so removed assets cannot linger. */
209
223
  export declare function syncWebAssets(source: string, output: string): void;
210
224
  /**
package/dist/index.js CHANGED
@@ -373,6 +373,46 @@ function renderAssetCatalog(output, config) {
373
373
  info: { author: "xcode", version: 1 }
374
374
  }, null, 2)}
375
375
  `);
376
+ renderLaunchLogo(catalog, config);
377
+ }
378
+ var LAUNCH_LOGO = "LaunchLogo";
379
+ function renderLaunchLogo(catalog, config) {
380
+ const imageset = join(catalog, `${LAUNCH_LOGO}.imageset`);
381
+ rmSync(imageset, { recursive: true, force: true });
382
+ if (!config.splashImagePath)
383
+ return;
384
+ mkdirSync(imageset, { recursive: true });
385
+ const images = [];
386
+ let vector = false;
387
+ for (const [source, dark] of [[config.splashImagePath, false], [config.splashImagePathDark, true]]) {
388
+ if (!source)
389
+ continue;
390
+ if (!existsSync(source))
391
+ throw new Error(`Splash image not found: ${source}`);
392
+ const extension = source.split(".").pop().toLowerCase();
393
+ if (extension === "svg" || extension === "pdf")
394
+ vector = true;
395
+ const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}.${extension}`;
396
+ cpSync(source, join(imageset, filename));
397
+ images.push({
398
+ ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
399
+ filename,
400
+ idiom: "universal"
401
+ });
402
+ }
403
+ writeFileSync(join(imageset, "Contents.json"), `${JSON.stringify({
404
+ images,
405
+ info: { author: "xcode", version: 1 },
406
+ ...vector ? { properties: { "preserves-vector-representation": true } } : {}
407
+ }, null, 2)}
408
+ `);
409
+ }
410
+ function renderLaunchScreen(config) {
411
+ const image = config.splashImagePath ? `
412
+ <key>UIImageName</key>
413
+ <string>${LAUNCH_LOGO}</string>` : "";
414
+ return ` <key>UIColorName</key>
415
+ <string>LaunchBackground</string>${image}`;
376
416
  }
377
417
  function syncWebAssets(source, output) {
378
418
  const sourcePath = resolve(source);
@@ -489,7 +529,7 @@ async function init(options) {
489
529
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
490
530
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
491
531
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
492
- const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
532
+ const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
493
533
  <true/>
494
534
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
495
535
  <true/>` : "");
@@ -804,6 +844,7 @@ export {
804
844
  renderRuntimeSettings,
805
845
  renderPrivacyManifest,
806
846
  renderOrientations,
847
+ renderLaunchScreen,
807
848
  renderEntitlements,
808
849
  renderDeviceFamilies,
809
850
  renderBackgroundModes,
@@ -817,5 +858,6 @@ export {
817
858
  installRuntime,
818
859
  init,
819
860
  build,
820
- bootSimulator
861
+ bootSimulator,
862
+ LAUNCH_LOGO
821
863
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.122",
3
+ "version": "0.0.123",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -225,8 +225,14 @@ struct CraftApp: App {
225
225
  if appState.config.renderer == "native" {
226
226
  CraftNativeScreen(config: appState.config)
227
227
  } else {
228
- CraftWebView(config: appState.config)
229
- .ignoresSafeArea()
228
+ // The page's own chrome, drawn natively over it: the tab
229
+ // bar the page asks for, and the splash until it is ready.
230
+ ZStack(alignment: .bottom) {
231
+ CraftWebView(config: appState.config)
232
+ .ignoresSafeArea()
233
+ CraftTabBarView()
234
+ CraftSplashView(background: appState.config.resolvedBackgroundColor)
235
+ }
230
236
  }
231
237
  }
232
238
  .preferredColorScheme(appState.config.colorScheme)
@@ -244,6 +250,255 @@ struct CraftApp: App {
244
250
  }
245
251
  }
246
252
 
253
+ // MARK: - Native chrome: the tab bar and the launch splash
254
+ //
255
+ // Both belong to the screen rather than to the page, so they are drawn here in
256
+ // SwiftUI and the page only describes them. The page reaches them through the
257
+ // `craftChrome` message handler, which exists from the first byte of the
258
+ // document; `window.craft` arrives only once the page has finished loading,
259
+ // images and all, and a tab bar or a splash that waited for it would be late
260
+ // on every launch.
261
+
262
+ final class CraftChrome: ObservableObject {
263
+ static let shared = CraftChrome()
264
+
265
+ struct Tab: Identifiable, Equatable {
266
+ let id: String
267
+ let title: String
268
+ let symbol: String
269
+ let badge: String?
270
+ }
271
+
272
+ @Published var tabs: [Tab] = []
273
+ @Published var selected: String?
274
+ @Published var tabBarVisible = false
275
+ @Published var splashVisible = true
276
+ @Published var tint: UIColor?
277
+
278
+ weak var webView: WKWebView?
279
+ /// What the tab bar covers at the bottom of the screen, in points.
280
+ private var occupied: CGFloat = 0
281
+ private var pendingHide: DispatchWorkItem?
282
+
283
+ private init() {
284
+ // However the page behaves, the splash never outstays ten seconds.
285
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in self?.hideSplash() }
286
+ }
287
+
288
+ func handle(_ body: [String: Any]) {
289
+ switch body["type"] as? String {
290
+ case "tabBar":
291
+ pendingHide?.cancel()
292
+ tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
293
+ guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
294
+ let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
295
+ return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge)
296
+ }
297
+ if let selected = body["selected"] as? String { self.selected = selected }
298
+ if let hex = body["tint"] as? String {
299
+ let light = UIColor(hex: hex)
300
+ let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
301
+ tint = light.map { light in dark.map { dark in UIColor { $0.userInterfaceStyle == .dark ? dark : light } } ?? light }
302
+ }
303
+ withAnimation(.easeOut(duration: 0.2)) { tabBarVisible = !tabs.isEmpty }
304
+ publishLayout()
305
+ case "selectTab":
306
+ if let id = body["id"] as? String, id != selected {
307
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { selected = id }
308
+ }
309
+ case "hideTabBar":
310
+ // Later, and only if no screen asks for the bar meanwhile: moving
311
+ // between two screens that both have it unmounts one bar and
312
+ // mounts the next, and the bar must not blink in between.
313
+ pendingHide?.cancel()
314
+ let hide = DispatchWorkItem { [weak self] in
315
+ withAnimation(.easeIn(duration: 0.18)) { self?.tabBarVisible = false }
316
+ self?.occupied = 0
317
+ self?.publishLayout()
318
+ }
319
+ pendingHide = hide
320
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: hide)
321
+ case "ready":
322
+ hideSplash()
323
+ default:
324
+ break
325
+ }
326
+ }
327
+
328
+ /// A tap on a tab: the page navigates, and says which tab is current.
329
+ func tap(_ tab: Tab) {
330
+ UISelectionFeedbackGenerator().selectionChanged()
331
+ if tab.id != selected {
332
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { selected = tab.id }
333
+ }
334
+ emit("craftTabSelect", ["id": tab.id])
335
+ }
336
+
337
+ func hideSplash() {
338
+ guard splashVisible else { return }
339
+ withAnimation(.easeOut(duration: 0.28)) { splashVisible = false }
340
+ }
341
+
342
+ /// A page that loaded and never said it was ready still gets shown.
343
+ func pageFinished() {
344
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in self?.hideSplash() }
345
+ }
346
+
347
+ func layoutChanged(_ height: CGFloat) {
348
+ let rounded = height.rounded()
349
+ guard rounded != occupied else { return }
350
+ occupied = rounded
351
+ publishLayout()
352
+ }
353
+
354
+ /// The bar's height as `--craft-tab-bar-height`, so the page leaves room
355
+ /// for it, and as an event for anything that measures.
356
+ func publishLayout() {
357
+ let height = tabBarVisible ? Int(occupied) : 0
358
+ let script = "document.documentElement.style.setProperty('--craft-tab-bar-height','\(height)px');window.dispatchEvent(new CustomEvent('craftTabBarLayout',{detail:{height:\(height)}}));"
359
+ DispatchQueue.main.async { [weak self] in self?.webView?.evaluateJavaScript(script, completionHandler: nil) }
360
+ }
361
+
362
+ private func emit(_ event: String, _ detail: [String: Any]) {
363
+ guard let data = try? JSONSerialization.data(withJSONObject: detail),
364
+ let json = String(data: data, encoding: .utf8) else { return }
365
+ let script = "window.dispatchEvent(new CustomEvent('\(event)',{detail:\(json)}));"
366
+ DispatchQueue.main.async { [weak self] in self?.webView?.evaluateJavaScript(script, completionHandler: nil) }
367
+ }
368
+ }
369
+
370
+ /// Receives the page's chrome messages. Only from a trusted origin, like the bridge.
371
+ final class CraftChromeRelay: NSObject, WKScriptMessageHandler {
372
+ private let trusts: (WKSecurityOrigin) -> Bool
373
+
374
+ init(trusts: @escaping (WKSecurityOrigin) -> Bool) {
375
+ self.trusts = trusts
376
+ }
377
+
378
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
379
+ guard message.frameInfo.isMainFrame, trusts(message.frameInfo.securityOrigin),
380
+ let body = message.body as? [String: Any] else { return }
381
+ DispatchQueue.main.async { CraftChrome.shared.handle(body) }
382
+ }
383
+ }
384
+
385
+ /// The floating tab bar: Liquid Glass on iOS 26 and later, a material capsule before.
386
+ struct CraftTabBarView: View {
387
+ @ObservedObject private var chrome = CraftChrome.shared
388
+ @Namespace private var selection
389
+
390
+ var body: some View {
391
+ if chrome.tabBarVisible && !chrome.tabs.isEmpty {
392
+ bar
393
+ .padding(.horizontal, 16)
394
+ .padding(.bottom, 4)
395
+ .background(GeometryReader { proxy in
396
+ Color.clear
397
+ .onAppear { report(proxy) }
398
+ .onChange(of: proxy.frame(in: .global)) { _ in report(proxy) }
399
+ })
400
+ .transition(.move(edge: .bottom).combined(with: .opacity))
401
+ .ignoresSafeArea(.keyboard)
402
+ }
403
+ }
404
+
405
+ private func report(_ proxy: GeometryProxy) {
406
+ let screen = UIScreen.main.bounds.height
407
+ chrome.layoutChanged(max(0, screen - proxy.frame(in: .global).minY))
408
+ }
409
+
410
+ @ViewBuilder private var bar: some View {
411
+ if #available(iOS 26.0, *) {
412
+ GlassEffectContainer {
413
+ items
414
+ .padding(4)
415
+ .glassEffect(.regular.interactive(), in: .capsule)
416
+ }
417
+ } else {
418
+ items
419
+ .padding(4)
420
+ .background(.ultraThinMaterial, in: Capsule())
421
+ .overlay(Capsule().strokeBorder(Color.white.opacity(0.35), lineWidth: 0.5))
422
+ .shadow(color: .black.opacity(0.12), radius: 18, y: 6)
423
+ }
424
+ }
425
+
426
+ private var items: some View {
427
+ HStack(spacing: 0) {
428
+ ForEach(chrome.tabs) { tab in
429
+ let isSelected = tab.id == chrome.selected
430
+ Button { chrome.tap(tab) } label: {
431
+ VStack(spacing: 3) {
432
+ ZStack(alignment: .topTrailing) {
433
+ Image(systemName: symbol(tab.symbol, selected: isSelected))
434
+ .font(.system(size: 20, weight: isSelected ? .semibold : .regular))
435
+ .frame(height: 26)
436
+ if let badge = tab.badge {
437
+ Text(badge)
438
+ .font(.system(size: 10, weight: .bold))
439
+ .foregroundStyle(.white)
440
+ .padding(.horizontal, 4)
441
+ .frame(minWidth: 16, minHeight: 16)
442
+ .background(Capsule().fill(Color.red))
443
+ .offset(x: 10, y: -4)
444
+ }
445
+ }
446
+ Text(tab.title)
447
+ .font(.system(size: 11, weight: isSelected ? .semibold : .medium))
448
+ .lineLimit(1)
449
+ .minimumScaleFactor(0.8)
450
+ }
451
+ .foregroundStyle(isSelected ? selectedColor : Color.primary)
452
+ .frame(maxWidth: .infinity, minHeight: 54)
453
+ .background {
454
+ if isSelected {
455
+ Capsule()
456
+ .fill(Color.primary.opacity(0.09))
457
+ .matchedGeometryEffect(id: "selection", in: selection)
458
+ }
459
+ }
460
+ .contentShape(Capsule())
461
+ }
462
+ .buttonStyle(.plain)
463
+ .accessibilityLabel(tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
464
+ .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
465
+ }
466
+ }
467
+ }
468
+
469
+ private var selectedColor: Color {
470
+ chrome.tint.map { Color($0) } ?? Color.primary
471
+ }
472
+
473
+ /// The filled variant for the current tab, when the symbol has one.
474
+ private func symbol(_ name: String, selected: Bool) -> String {
475
+ guard selected, !name.hasSuffix(".fill"), UIImage(systemName: "\(name).fill") != nil else { return name }
476
+ return "\(name).fill"
477
+ }
478
+ }
479
+
480
+ /// The launch screen, held until the page is ready: the same colour and the
481
+ /// same logo at the same place, so the hand-over from the system's launch
482
+ /// screen cannot be seen.
483
+ struct CraftSplashView: View {
484
+ @ObservedObject private var chrome = CraftChrome.shared
485
+ let background: UIColor
486
+
487
+ var body: some View {
488
+ if chrome.splashVisible {
489
+ ZStack {
490
+ Color(background)
491
+ if let logo = UIImage(named: "LaunchLogo") {
492
+ Image(uiImage: logo)
493
+ }
494
+ }
495
+ .ignoresSafeArea()
496
+ .allowsHitTesting(true)
497
+ .transition(.opacity)
498
+ }
499
+ }
500
+ }
501
+
247
502
  // MARK: - Deep Link Manager
248
503
  class DeepLinkManager {
249
504
  static let shared = DeepLinkManager()
@@ -634,6 +889,14 @@ struct CraftWebView: UIViewRepresentable {
634
889
  // Add native bridge
635
890
  let contentController = WKUserContentController()
636
891
  contentController.add(context.coordinator, name: "craft")
892
+ // The tab bar and the splash, outside the bridge's action dispatch:
893
+ // they are the screen's, not a device API, and the page needs them
894
+ // before the bridge is installed.
895
+ let coordinator = context.coordinator
896
+ contentController.add(CraftChromeRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftChrome")
897
+ // From the first byte, so the page's CSS knows before it paints that
898
+ // the shell draws the tab bar, and does not draw its own for a frame.
899
+ contentController.addUserScript(WKUserScript(source: "document.documentElement.setAttribute('data-craft-chrome','ios')", injectionTime: .atDocumentStart, forMainFrameOnly: true))
637
900
  webConfig.userContentController = contentController
638
901
  #if DEBUG
639
902
  // The page's errors and console, in the device log, so a debug build
@@ -664,6 +927,7 @@ struct CraftWebView: UIViewRepresentable {
664
927
  // Register with DeepLinkManager
665
928
  DeepLinkManager.shared.setWebView(webView)
666
929
  CraftEventManager.shared.setWebView(webView)
930
+ CraftChrome.shared.webView = webView
667
931
 
668
932
  // Parse background color
669
933
  let bgColor = config.resolvedBackgroundColor
@@ -1737,6 +2001,8 @@ struct CraftWebView: UIViewRepresentable {
1737
2001
 
1738
2002
  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
1739
2003
  self.webView = webView
2004
+ CraftChrome.shared.pageFinished()
2005
+ CraftChrome.shared.publishLayout()
1740
2006
  guard isTrustedURL(webView.url) else { return }
1741
2007
  injectNativeBridge()
1742
2008
  }
@@ -1852,6 +2118,11 @@ struct CraftWebView: UIViewRepresentable {
1852
2118
  /// now, and `scripts/mobile-e2e.ts` runs a real round trip on a booted
1853
2119
  /// simulator so a future hardening pass cannot quietly take the bridge
1854
2120
  /// away again.
2121
+ /// Whether a message from this origin may drive the app's chrome.
2122
+ func trusts(_ origin: WKSecurityOrigin) -> Bool {
2123
+ isTrustedOrigin(scheme: origin.protocol, host: origin.host, port: origin.port)
2124
+ }
2125
+
1855
2126
  private func isTrustedOrigin(scheme: String, host: String, port: Int) -> Bool {
1856
2127
  if scheme == "craft" && host == "app" { return true }
1857
2128
  if scheme == "file" { return true }
@@ -26,8 +26,7 @@
26
26
  <{{NON_EXEMPT_ENCRYPTION}}/>
27
27
  <key>UILaunchScreen</key>
28
28
  <dict>
29
- <key>UIColorName</key>
30
- <string>LaunchBackground</string>
29
+ {{LAUNCH_SCREEN}}
31
30
  </dict>
32
31
  <key>UIRequiredDeviceCapabilities</key>
33
32
  <array>