craft-native 0.0.121 → 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.
@@ -754,6 +754,50 @@ export declare const network: {
754
754
  getStatus(): Promise<NetworkStatus>;
755
755
  onChange(callback: (status: NetworkStatus) => void): () => void;
756
756
  };
757
+ /** A tab the native tab bar shows. */
758
+ export interface NativeTab {
759
+ /** Comes back in `onSelect` when the tab is tapped. */
760
+ id: string;
761
+ title: string;
762
+ /** An SF Symbol name, such as `calendar`; the current tab draws its `.fill` variant when there is one. */
763
+ symbol: string;
764
+ /** A count on the icon; empty draws none. */
765
+ badge?: string;
766
+ }
767
+ export interface NativeTabBarOptions {
768
+ /** The current tab's id. */
769
+ selected?: string;
770
+ /** The current tab's colour, as `#rrggbb`; the system's label colour if unset. */
771
+ tint?: string;
772
+ tintDark?: string;
773
+ }
774
+ /**
775
+ * The phone's own tab bar, floating over the page: Liquid Glass on iOS 26 and
776
+ * later. The page describes it and keeps it on the current tab; a tap comes
777
+ * back through `onSelect` for the page to navigate. Where there is no native
778
+ * bar (a browser, Android for now), `isAvailable()` is false and every call is
779
+ * a no-op answering false, so the page draws its own.
780
+ *
781
+ * While the bar shows, `--craft-tab-bar-height` on the root element says how
782
+ * much of the screen's bottom it covers.
783
+ */
784
+ export declare const tabBar: {
785
+ isAvailable(): boolean;
786
+ set(tabs: NativeTab[], options?: NativeTabBarOptions): boolean;
787
+ select(id: string): boolean;
788
+ hide(): boolean;
789
+ onSelect(callback: (id: string) => void): () => void;
790
+ };
791
+ /**
792
+ * The launch splash, which the shell holds over the page until the page says
793
+ * it is ready, so the app goes from its launch screen to content with no blank
794
+ * page in between. Call `hide()` once the first screen has painted. The shell
795
+ * hides it anyway a moment after the page finishes loading, and after ten
796
+ * seconds at most.
797
+ */
798
+ export declare const splash: {
799
+ hide(): boolean;
800
+ };
757
801
  export declare const appReview: {
758
802
  request(): Promise<boolean>;
759
803
  };
@@ -911,5 +955,7 @@ declare const mobile: {
911
955
  health: typeof health;
912
956
  liveActivities: typeof liveActivities;
913
957
  watchConnectivity: typeof watchConnectivity;
958
+ tabBar: typeof tabBar;
959
+ splash: typeof splash;
914
960
  };
915
961
  export default mobile;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.121";
3306
+ var version = "0.0.123";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.121";
313
+ var version = "0.0.123";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.121";
61
+ var version = "0.0.123";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -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
  /**
@@ -79,6 +79,18 @@ function renderAppearance(config) {
79
79
  return { interfaceStyle: "Light", statusBarStyle: "UIStatusBarStyleDarkContent" };
80
80
  return { interfaceStyle: "Dark", statusBarStyle: "UIStatusBarStyleLightContent" };
81
81
  }
82
+ var LINKED_PURPOSES = {
83
+ NSSpeechRecognitionUsageDescription: "speech recognition",
84
+ NSMicrophoneUsageDescription: "the microphone",
85
+ NSCameraUsageDescription: "the camera",
86
+ NSPhotoLibraryUsageDescription: "your photo library",
87
+ NSLocationWhenInUseUsageDescription: "your location",
88
+ NSContactsUsageDescription: "your contacts",
89
+ NSCalendarsUsageDescription: "your calendar",
90
+ NSBluetoothAlwaysUsageDescription: "Bluetooth",
91
+ NSMotionUsageDescription: "motion data",
92
+ NSFaceIDUsageDescription: "Face ID"
93
+ };
82
94
  function renderUsageDescriptions(config) {
83
95
  const entries = [
84
96
  [config.enableSpeechRecognition, "NSSpeechRecognitionUsageDescription", `${config.appName} uses speech recognition for voice input.`],
@@ -97,7 +109,12 @@ function renderUsageDescriptions(config) {
97
109
  [config.enableHealthKit, "NSHealthUpdateUsageDescription", `${config.appName} writes health data only with your permission.`],
98
110
  [config.enableBiometric, "NSFaceIDUsageDescription", `${config.appName} uses Face ID to protect your account.`]
99
111
  ];
100
- return entries.filter(([enabled]) => enabled).map(([, key, value]) => plistString(key, value)).join(`
112
+ return entries.map(([enabled, key, value]) => {
113
+ if (enabled)
114
+ return [key, value];
115
+ const linked = LINKED_PURPOSES[key];
116
+ return linked ? [key, `${config.appName} asks for ${linked} only in a feature that needs it.`] : null;
117
+ }).filter((entry) => entry !== null).map(([key, value]) => plistString(key, value)).join(`
101
118
  `);
102
119
  }
103
120
  function renderOrientations(config) {
@@ -356,6 +373,46 @@ function renderAssetCatalog(output, config) {
356
373
  info: { author: "xcode", version: 1 }
357
374
  }, null, 2)}
358
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}`;
359
416
  }
360
417
  function syncWebAssets(source, output) {
361
418
  const sourcePath = resolve(source);
@@ -472,7 +529,7 @@ async function init(options) {
472
529
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
473
530
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
474
531
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
475
- 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>
476
533
  <true/>
477
534
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
478
535
  <true/>` : "");
@@ -787,6 +844,7 @@ export {
787
844
  renderRuntimeSettings,
788
845
  renderPrivacyManifest,
789
846
  renderOrientations,
847
+ renderLaunchScreen,
790
848
  renderEntitlements,
791
849
  renderDeviceFamilies,
792
850
  renderBackgroundModes,
@@ -800,5 +858,6 @@ export {
800
858
  installRuntime,
801
859
  init,
802
860
  build,
803
- bootSimulator
861
+ bootSimulator,
862
+ LAUNCH_LOGO
804
863
  };
@@ -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>
package/dist/mobile.js CHANGED
@@ -701,6 +701,53 @@ var network = {
701
701
  };
702
702
  }
703
703
  };
704
+ function chromeHandler() {
705
+ if (typeof window === "undefined")
706
+ return null;
707
+ const handler = window.webkit?.messageHandlers?.craftChrome;
708
+ return handler && typeof handler.postMessage === "function" ? handler : null;
709
+ }
710
+ function postChrome(message) {
711
+ const handler = chromeHandler();
712
+ if (!handler)
713
+ return false;
714
+ try {
715
+ handler.postMessage(message);
716
+ return true;
717
+ } catch {
718
+ return false;
719
+ }
720
+ }
721
+ var tabBar = {
722
+ isAvailable() {
723
+ return chromeHandler() !== null;
724
+ },
725
+ set(tabs, options = {}) {
726
+ return postChrome({ type: "tabBar", tabs, ...options });
727
+ },
728
+ select(id) {
729
+ return postChrome({ type: "selectTab", id });
730
+ },
731
+ hide() {
732
+ return postChrome({ type: "hideTabBar" });
733
+ },
734
+ onSelect(callback) {
735
+ if (typeof window === "undefined")
736
+ return () => {};
737
+ const listener = (event) => {
738
+ const id = event.detail?.id;
739
+ if (typeof id === "string")
740
+ callback(id);
741
+ };
742
+ window.addEventListener("craftTabSelect", listener);
743
+ return () => window.removeEventListener("craftTabSelect", listener);
744
+ }
745
+ };
746
+ var splash = {
747
+ hide() {
748
+ return postChrome({ type: "ready" });
749
+ }
750
+ };
704
751
  var appReview = {
705
752
  async request() {
706
753
  const craft = getCraftRoot();
@@ -879,6 +926,8 @@ function getCraftMobile() {
879
926
  }
880
927
  export {
881
928
  watchConnectivity,
929
+ tabBar,
930
+ splash,
882
931
  speech,
883
932
  share,
884
933
  secureStorage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.121",
3
+ "version": "0.0.123",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",