@craft-native/ios 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.
- package/dist/cli.js +59 -2
- package/dist/index.d.ts +14 -0
- package/dist/index.js +62 -3
- package/package.json +1 -1
- package/templates/CraftApp.swift +273 -2
- package/templates/Info.plist.template +1 -2
package/dist/cli.js
CHANGED
|
@@ -84,6 +84,18 @@ function renderAppearance(config) {
|
|
|
84
84
|
return { interfaceStyle: "Light", statusBarStyle: "UIStatusBarStyleDarkContent" };
|
|
85
85
|
return { interfaceStyle: "Dark", statusBarStyle: "UIStatusBarStyleLightContent" };
|
|
86
86
|
}
|
|
87
|
+
var LINKED_PURPOSES = {
|
|
88
|
+
NSSpeechRecognitionUsageDescription: "speech recognition",
|
|
89
|
+
NSMicrophoneUsageDescription: "the microphone",
|
|
90
|
+
NSCameraUsageDescription: "the camera",
|
|
91
|
+
NSPhotoLibraryUsageDescription: "your photo library",
|
|
92
|
+
NSLocationWhenInUseUsageDescription: "your location",
|
|
93
|
+
NSContactsUsageDescription: "your contacts",
|
|
94
|
+
NSCalendarsUsageDescription: "your calendar",
|
|
95
|
+
NSBluetoothAlwaysUsageDescription: "Bluetooth",
|
|
96
|
+
NSMotionUsageDescription: "motion data",
|
|
97
|
+
NSFaceIDUsageDescription: "Face ID"
|
|
98
|
+
};
|
|
87
99
|
function renderUsageDescriptions(config) {
|
|
88
100
|
const entries = [
|
|
89
101
|
[config.enableSpeechRecognition, "NSSpeechRecognitionUsageDescription", `${config.appName} uses speech recognition for voice input.`],
|
|
@@ -102,7 +114,12 @@ function renderUsageDescriptions(config) {
|
|
|
102
114
|
[config.enableHealthKit, "NSHealthUpdateUsageDescription", `${config.appName} writes health data only with your permission.`],
|
|
103
115
|
[config.enableBiometric, "NSFaceIDUsageDescription", `${config.appName} uses Face ID to protect your account.`]
|
|
104
116
|
];
|
|
105
|
-
return entries.
|
|
117
|
+
return entries.map(([enabled, key, value]) => {
|
|
118
|
+
if (enabled)
|
|
119
|
+
return [key, value];
|
|
120
|
+
const linked = LINKED_PURPOSES[key];
|
|
121
|
+
return linked ? [key, `${config.appName} asks for ${linked} only in a feature that needs it.`] : null;
|
|
122
|
+
}).filter((entry) => entry !== null).map(([key, value]) => plistString(key, value)).join(`
|
|
106
123
|
`);
|
|
107
124
|
}
|
|
108
125
|
function renderOrientations(config) {
|
|
@@ -361,6 +378,46 @@ function renderAssetCatalog(output, config) {
|
|
|
361
378
|
info: { author: "xcode", version: 1 }
|
|
362
379
|
}, null, 2)}
|
|
363
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}`;
|
|
364
421
|
}
|
|
365
422
|
function syncWebAssets(source, output) {
|
|
366
423
|
const sourcePath = resolve(source);
|
|
@@ -477,7 +534,7 @@ async function init(options) {
|
|
|
477
534
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
478
535
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
479
536
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
480
|
-
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>
|
|
481
538
|
<true/>
|
|
482
539
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
483
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
|
@@ -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.
|
|
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
|
};
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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 }
|