craft-native 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/api/mobile.d.ts +46 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +14 -0
- package/dist/ios/src/index.js +44 -2
- package/dist/ios/templates/CraftApp.swift +273 -2
- package/dist/ios/templates/Info.plist.template +1 -2
- package/dist/mobile.js +49 -0
- package/package.json +1 -1
package/dist/api/mobile.d.ts
CHANGED
|
@@ -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.
|
|
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
package/dist/index.js
CHANGED
package/dist/ios/src/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/ios/src/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
|
};
|
|
@@ -225,8 +225,14 @@ struct CraftApp: App {
|
|
|
225
225
|
if appState.config.renderer == "native" {
|
|
226
226
|
CraftNativeScreen(config: appState.config)
|
|
227
227
|
} else {
|
|
228
|
-
|
|
229
|
-
|
|
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 }
|
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,
|