@craft-native/ios 0.0.122 → 0.0.124
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/README.md +120 -2
- package/dist/cli.js +169 -43
- package/dist/index.d.ts +90 -2
- package/dist/index.js +177 -44
- package/package.json +1 -1
- package/templates/CraftApp.swift +1341 -51
- package/templates/CraftNativeActions.swift +97 -9
- package/templates/CraftNativeFlatList.swift +92 -12
- package/templates/CraftNativeScreen.swift +876 -64
- package/templates/CraftNativeUI.swift +562 -0
- package/templates/Info.plist.template +3 -3
package/README.md
CHANGED
|
@@ -64,6 +64,9 @@ Build native iOS apps with web technologies using Craft.
|
|
|
64
64
|
|
|
65
65
|
The iOS support is built into the main `craft` CLI:
|
|
66
66
|
|
|
67
|
+
`FlatList` item roots receive the same `onLayout` callback as ordinary native
|
|
68
|
+
views when rows are materialized or recycled.
|
|
69
|
+
|
|
67
70
|
```bash
|
|
68
71
|
bun add craft-native
|
|
69
72
|
```
|
|
@@ -106,7 +109,7 @@ In Xcode:
|
|
|
106
109
|
|
|
107
110
|
### Experimental WebView-free screen
|
|
108
111
|
|
|
109
|
-
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `TextInput`, `Image`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
|
|
112
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `TextInput`, `ActivityIndicator`, `Image`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
|
|
110
113
|
|
|
111
114
|
```bash
|
|
112
115
|
craft ios init NativeDemo --renderer native --output ./ios-native
|
|
@@ -122,6 +125,9 @@ supports it and fall back to whole-document rendering for older bundles.
|
|
|
122
125
|
Updates reconcile the UIKit tree by sibling-scoped `key` (or `testID` for
|
|
123
126
|
existing screens); unkeyed children reuse their position. `TextInput` objects
|
|
124
127
|
therefore keep focus and selection while unrelated labels and buttons change.
|
|
128
|
+
Native `TextInput` also applies `placeholderTextColor`, `selectionColor`,
|
|
129
|
+
positive `maxLength`, `multiline`, and `numberOfLines` values while preserving
|
|
130
|
+
the WebView fallback for routes that do not opt into the renderer.
|
|
125
131
|
The protocol wire format, atomic validation, revisions, and error codes are in
|
|
126
132
|
the [Native STX guide](../../docs/guides/native-stx.md). On macOS,
|
|
127
133
|
`bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and
|
|
@@ -129,6 +135,8 @@ simulator UI tests.
|
|
|
129
135
|
|
|
130
136
|
For multiple native screens, configure named `.stx` routes and an initial screen in `stx-native.config.json` (see [`fixtures/native-navigation`](./fixtures/native-navigation)). Compile without a positional file: `stx-native compile --format bundle --output ./screen.js`. A screen can call `craft.navigation.push('details', { id: 7 })`, `.replace('home')`, or `.back()`; the destination reads `craft.route.params`. Craft uses a native navigation stack, so the back button and edge-swipe reveal the existing controller and retain its JavaScript and input state. `bun packages/ios/scripts/test-native-navigation.ts` compiles the fixture and runs its simulator test; the default WebView renderer is unchanged.
|
|
131
137
|
|
|
138
|
+
The optional Liquid Glass tab-bar styling is compiler-gated for Xcode 26/Swift 6.2 and later. Older SDKs compile the same tab bar with the material fallback, so generated projects remain buildable on Xcode 16.
|
|
139
|
+
|
|
132
140
|
The iOS host overlays `craft.config.json` on its defaults. Missing or `null` capability flags stay disabled; explicitly set a flag to `true` to enable it. Both the Swift host and Zig bridge reject malformed values and fall back to all capabilities disabled.
|
|
133
141
|
|
|
134
142
|
Edit `craft.config.json` in your iOS project:
|
|
@@ -203,12 +211,64 @@ development environment and Release builds use the production environment.
|
|
|
203
211
|
The generated entitlements select the correct value from the active Xcode
|
|
204
212
|
build configuration automatically.
|
|
205
213
|
|
|
214
|
+
### Launch, offline and the feel of the web view
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"splashMaxSeconds": 3,
|
|
219
|
+
"requestTimeoutSeconds": 10,
|
|
220
|
+
"allowsLinkPreview": false,
|
|
221
|
+
"keyboardAccessory": false,
|
|
222
|
+
"disableZoom": true,
|
|
223
|
+
"associatedDomains": ["hq.training"],
|
|
224
|
+
"backgroundRefresh": { "enabled": true, "minimumIntervalMinutes": 30 }
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- `splashMaxSeconds` (default 3): the splash stays until the page calls
|
|
229
|
+
`splash.hide()` or its first contentful paint is on screen, and never longer
|
|
230
|
+
than this.
|
|
231
|
+
- `requestTimeoutSeconds` (default 10): how long the app's own page may take
|
|
232
|
+
before the load counts as unreachable and the bundled page stands in.
|
|
233
|
+
- The bundled page `init` writes (`dist/index.html`, until your web assets
|
|
234
|
+
replace it) is an offline page in the app's colours, light and dark, whose
|
|
235
|
+
Retry loads the remote page again. An app whose page has a service worker
|
|
236
|
+
normally opens from it offline; this shows on a first launch without one or
|
|
237
|
+
after a hard failure. With `appBoundDomains` it is loaded as a page of the
|
|
238
|
+
remote origin, because WebKit refuses to navigate from an app-bound domain
|
|
239
|
+
to `craft://app`.
|
|
240
|
+
- `allowsLinkPreview` (default false): Safari's press-and-hold link previews.
|
|
241
|
+
- `keyboardAccessory` (default false): WebKit's previous/next/Done bar above
|
|
242
|
+
the keyboard. Change it at runtime with `craft.chrome.setKeyboardAccessory`.
|
|
243
|
+
- `disableZoom` (default true): no pinch or double-tap zoom, and the page's
|
|
244
|
+
viewport pinned at scale 1.
|
|
245
|
+
- `associatedDomains`: universal links. A bare host is signed as
|
|
246
|
+
`applinks:<host>`; a universal link reaches the page as `craftDeepLink`, like
|
|
247
|
+
a custom-scheme link.
|
|
248
|
+
- `backgroundRefresh`: a BGAppRefreshTask (`<bundleId>.refresh` unless
|
|
249
|
+
`identifier` is set), declared in `BGTaskSchedulerPermittedIdentifiers` with
|
|
250
|
+
the `fetch` background mode, scheduled no sooner than
|
|
251
|
+
`minimumIntervalMinutes` (default 15). It fires `craftBackgroundRefresh`.
|
|
252
|
+
|
|
253
|
+
The web view also: shows `alert`, `confirm` and `prompt` as system alerts;
|
|
254
|
+
opens `target=_blank` and `window.open` for the app's own origins in place and
|
|
255
|
+
anything else in `SFSafariViewController`; grants `getUserMedia` to the app's
|
|
256
|
+
own origin without WebKit's extra prompt; dismisses the keyboard with a drag;
|
|
257
|
+
allows picture in picture; ends its user agent with `Craft/<version>
|
|
258
|
+
<AppName>/<version>`; brings a page whose content process died back under the
|
|
259
|
+
splash, on the page it was showing, at most three times a minute before
|
|
260
|
+
showing the offline page; and saves its `interactionState` when the app goes to
|
|
261
|
+
the background, so a launch after iOS ended the app returns to the same screen
|
|
262
|
+
(same build only).
|
|
263
|
+
|
|
206
264
|
## JavaScript Bridge
|
|
207
265
|
|
|
208
266
|
Once Craft is initialized, the `window.craft` object is available:
|
|
209
267
|
|
|
210
268
|
```javascript
|
|
211
|
-
//
|
|
269
|
+
// window.craft is installed at document start, before the page's own scripts,
|
|
270
|
+
// so it can be used at once. craftReady still fires, once, when the document
|
|
271
|
+
// has been parsed, for pages that wait for it.
|
|
212
272
|
window.addEventListener('craftReady', (e) => {
|
|
213
273
|
console.log('Platform:', e.detail.platform); // 'ios'
|
|
214
274
|
console.log('Capabilities:', e.detail.capabilities);
|
|
@@ -600,6 +660,64 @@ window.craft.watch.onReachabilityChange((status) => {
|
|
|
600
660
|
});
|
|
601
661
|
```
|
|
602
662
|
|
|
663
|
+
### Native UI, appearance and background (iOS)
|
|
664
|
+
|
|
665
|
+
Every call below returns a promise that settles and never throws: a failure
|
|
666
|
+
answers with what "nothing happened" means for it. `craft-native/mobile`
|
|
667
|
+
exports the same calls typed (`dialog`, `contextMenu`, `browser`, `symbols`,
|
|
668
|
+
`statusBar`, `chrome`, `refresh`, `background`, `appearance`), resolving
|
|
669
|
+
outside the shell too.
|
|
670
|
+
|
|
671
|
+
```javascript
|
|
672
|
+
// Haptics by kind, each on UIKit's own generator, kept warm.
|
|
673
|
+
await craft.haptics.impact('rigid'); // light, medium, heavy, soft, rigid
|
|
674
|
+
await craft.haptics.notification('success'); // success, warning, error
|
|
675
|
+
await craft.haptics.selection();
|
|
676
|
+
await craft.haptics.prepare('selection'); // before a gesture that will play one
|
|
677
|
+
|
|
678
|
+
// System dialogs. An anchor is a rect in CSS pixels or an element.
|
|
679
|
+
await craft.dialog.alert({ title: 'Saved' });
|
|
680
|
+
const ok = await craft.dialog.confirm({ title: 'Delete workout?', confirmLabel: 'Delete', destructive: true });
|
|
681
|
+
const choice = await craft.dialog.actionSheet({
|
|
682
|
+
title: 'Workout',
|
|
683
|
+
anchor: button,
|
|
684
|
+
actions: [{ id: 'edit', title: 'Edit' }, { id: 'delete', title: 'Delete', style: 'destructive' }],
|
|
685
|
+
}); // the id, or null when dismissed
|
|
686
|
+
|
|
687
|
+
// The system context menu (iOS 17.4+; the edit menu, then an action sheet, before).
|
|
688
|
+
const picked = await craft.contextMenu.show({
|
|
689
|
+
anchor: row,
|
|
690
|
+
items: [{ id: 'duplicate', title: 'Duplicate', symbol: 'plus.square.on.square' }, { id: 'delete', title: 'Delete', symbol: 'trash', destructive: true }],
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// SFSafariViewController, or the system web sign-in sheet.
|
|
694
|
+
await craft.browser.open('https://example.com');
|
|
695
|
+
const { url, cancelled } = await craft.browser.open(authorizeURL, { mode: 'auth', callbackScheme: 'hqtraining' });
|
|
696
|
+
|
|
697
|
+
// An SF Symbol as a PNG data URL.
|
|
698
|
+
img.src = await craft.symbols.image('heart.fill', { pointSize: 22, weight: 'semibold', color: '#e11d48' });
|
|
699
|
+
|
|
700
|
+
// Chrome around the page.
|
|
701
|
+
await craft.statusBar.setStyle('light'); // default, light, dark
|
|
702
|
+
await craft.chrome.setUnderPageColor('#020617'); // shown past the page's edges
|
|
703
|
+
await craft.chrome.setKeyboardAccessory(true);
|
|
704
|
+
|
|
705
|
+
// Native pull to refresh.
|
|
706
|
+
await craft.refresh.enable({ tintColor: '#22c55e' });
|
|
707
|
+
craft.refresh.onRefresh(async () => { await reload(); craft.refresh.end(); });
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Events on `window`:
|
|
711
|
+
|
|
712
|
+
| Event | Detail | When |
|
|
713
|
+
| --- | --- | --- |
|
|
714
|
+
| `craftAppearance` | `{ contentSizeCategory, fontScale, reduceMotion, reduceTransparency, colorScheme }` | with `craftReady`, and whenever Dynamic Type, Reduce Motion, Reduce Transparency or Light/Dark changes. The root element also carries `--craft-font-scale`, `data-craft-reduce-motion`, `data-craft-reduce-transparency` and `data-craft-content-size` from the first byte; `craft.appearance` reads the current values. |
|
|
715
|
+
| `craftResume` | `{ backgroundedMs }` | the app returned to the foreground |
|
|
716
|
+
| `craftMemoryWarning` | `{}` | iOS is short of memory |
|
|
717
|
+
| `craftSilentPush` | `{ id, payload }` | a `content-available` push; answer with `craft.background.complete(ok, id)` within 25 s |
|
|
718
|
+
| `craftBackgroundRefresh` | `{ id }` | the scheduled refresh ran; answer the same way |
|
|
719
|
+
| `craftRefresh` | `{}` | the native pull-to-refresh was pulled |
|
|
720
|
+
|
|
603
721
|
## CLI Reference
|
|
604
722
|
|
|
605
723
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,13 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statS
|
|
|
9
9
|
import { dirname, join, resolve } from "path";
|
|
10
10
|
var {$ } = globalThis.Bun;
|
|
11
11
|
var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
|
|
12
|
+
var CRAFT_VERSION = (() => {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
|
|
15
|
+
} catch {
|
|
16
|
+
return "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
12
19
|
var DEFAULT_CONFIG = {
|
|
13
20
|
renderer: "web",
|
|
14
21
|
version: "1.0.0",
|
|
@@ -176,6 +183,8 @@ function plistArray(values, indent = 2) {
|
|
|
176
183
|
}
|
|
177
184
|
function renderBackgroundModes(config) {
|
|
178
185
|
const modes = new Set;
|
|
186
|
+
if (config.backgroundRefresh?.enabled)
|
|
187
|
+
modes.add("fetch");
|
|
179
188
|
if (config.enableBackgroundLocation)
|
|
180
189
|
modes.add("location");
|
|
181
190
|
if (config.enableBackgroundTasks)
|
|
@@ -189,12 +198,30 @@ function renderBackgroundModes(config) {
|
|
|
189
198
|
${plistArray([...modes])}
|
|
190
199
|
</array>`;
|
|
191
200
|
}
|
|
201
|
+
function backgroundRefreshIdentifier(config) {
|
|
202
|
+
if (!config.backgroundRefresh?.enabled)
|
|
203
|
+
return null;
|
|
204
|
+
return config.backgroundRefresh.identifier?.trim() || `${config.bundleId}.refresh`;
|
|
205
|
+
}
|
|
206
|
+
function renderBackgroundTaskIdentifiers(config) {
|
|
207
|
+
const identifier = backgroundRefreshIdentifier(config);
|
|
208
|
+
if (!identifier)
|
|
209
|
+
return "";
|
|
210
|
+
return ` <key>BGTaskSchedulerPermittedIdentifiers</key>
|
|
211
|
+
<array>
|
|
212
|
+
${plistArray([identifier])}
|
|
213
|
+
</array>`;
|
|
214
|
+
}
|
|
215
|
+
function associatedDomainEntries(domains) {
|
|
216
|
+
return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
|
|
217
|
+
}
|
|
192
218
|
function renderEntitlements(config) {
|
|
193
219
|
const entries = [];
|
|
194
|
-
|
|
220
|
+
const associatedDomains = associatedDomainEntries(config.associatedDomains);
|
|
221
|
+
if (associatedDomains.length) {
|
|
195
222
|
entries.push(` <key>com.apple.developer.associated-domains</key>
|
|
196
223
|
<array>
|
|
197
|
-
${plistArray(
|
|
224
|
+
${plistArray(associatedDomains)}
|
|
198
225
|
</array>`);
|
|
199
226
|
}
|
|
200
227
|
if (config.appGroups?.length) {
|
|
@@ -378,6 +405,142 @@ function renderAssetCatalog(output, config) {
|
|
|
378
405
|
info: { author: "xcode", version: 1 }
|
|
379
406
|
}, null, 2)}
|
|
380
407
|
`);
|
|
408
|
+
renderLaunchLogo(catalog, config);
|
|
409
|
+
}
|
|
410
|
+
var LAUNCH_LOGO = "LaunchLogo";
|
|
411
|
+
function renderLaunchLogo(catalog, config) {
|
|
412
|
+
const imageset = join(catalog, `${LAUNCH_LOGO}.imageset`);
|
|
413
|
+
rmSync(imageset, { recursive: true, force: true });
|
|
414
|
+
if (!config.splashImagePath)
|
|
415
|
+
return;
|
|
416
|
+
mkdirSync(imageset, { recursive: true });
|
|
417
|
+
const images = [];
|
|
418
|
+
let vector = false;
|
|
419
|
+
for (const [source, dark] of [[config.splashImagePath, false], [config.splashImagePathDark, true]]) {
|
|
420
|
+
if (!source)
|
|
421
|
+
continue;
|
|
422
|
+
if (!existsSync(source))
|
|
423
|
+
throw new Error(`Splash image not found: ${source}`);
|
|
424
|
+
const extension = source.split(".").pop().toLowerCase();
|
|
425
|
+
if (extension === "svg" || extension === "pdf")
|
|
426
|
+
vector = true;
|
|
427
|
+
const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}.${extension}`;
|
|
428
|
+
cpSync(source, join(imageset, filename));
|
|
429
|
+
images.push({
|
|
430
|
+
...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
|
|
431
|
+
filename,
|
|
432
|
+
idiom: "universal"
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
writeFileSync(join(imageset, "Contents.json"), `${JSON.stringify({
|
|
436
|
+
images,
|
|
437
|
+
info: { author: "xcode", version: 1 },
|
|
438
|
+
...vector ? { properties: { "preserves-vector-representation": true } } : {}
|
|
439
|
+
}, null, 2)}
|
|
440
|
+
`);
|
|
441
|
+
}
|
|
442
|
+
function renderLaunchScreen(config) {
|
|
443
|
+
const image = config.splashImagePath ? `
|
|
444
|
+
<key>UIImageName</key>
|
|
445
|
+
<string>${LAUNCH_LOGO}</string>` : "";
|
|
446
|
+
return ` <key>UIColorName</key>
|
|
447
|
+
<string>LaunchBackground</string>${image}`;
|
|
448
|
+
}
|
|
449
|
+
function hexChannels(hex) {
|
|
450
|
+
const value = hex?.trim().replace(/^#/, "") ?? "";
|
|
451
|
+
const full = value.length === 3 ? [...value].map((digit) => digit + digit).join("") : value.slice(0, 6);
|
|
452
|
+
if (!/^[0-9a-f]{6}$/i.test(full))
|
|
453
|
+
return null;
|
|
454
|
+
return [0, 2, 4].map((index) => Number.parseInt(full.slice(index, index + 2), 16));
|
|
455
|
+
}
|
|
456
|
+
function readableTextOn(background) {
|
|
457
|
+
const channels = hexChannels(background);
|
|
458
|
+
if (!channels)
|
|
459
|
+
return "#0f172a";
|
|
460
|
+
const [r, g, b] = channels.map((channel) => {
|
|
461
|
+
const c = channel / 255;
|
|
462
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
463
|
+
});
|
|
464
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
465
|
+
return luminance > 0.4 ? "#0f172a" : "#f8fafc";
|
|
466
|
+
}
|
|
467
|
+
function renderOfflinePage(config) {
|
|
468
|
+
const light = hexChannels(config.backgroundColor) ? config.backgroundColor : "#ffffff";
|
|
469
|
+
const dark = hexChannels(config.backgroundColorDark) ? config.backgroundColorDark : light;
|
|
470
|
+
const name = xmlEscape(config.appName);
|
|
471
|
+
return `<!DOCTYPE html>
|
|
472
|
+
<html lang="en">
|
|
473
|
+
<head>
|
|
474
|
+
<meta charset="UTF-8">
|
|
475
|
+
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
476
|
+
<meta name="color-scheme" content="light dark">
|
|
477
|
+
<title>${name}</title>
|
|
478
|
+
<style>
|
|
479
|
+
:root { --background: ${light}; --text: ${readableTextOn(light)}; }
|
|
480
|
+
@media (prefers-color-scheme: dark) {
|
|
481
|
+
:root { --background: ${dark}; --text: ${readableTextOn(dark)}; }
|
|
482
|
+
}
|
|
483
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
484
|
+
html, body { height: 100%; }
|
|
485
|
+
body {
|
|
486
|
+
font: -apple-system-body;
|
|
487
|
+
font-family: -apple-system, system-ui, sans-serif;
|
|
488
|
+
background: var(--background);
|
|
489
|
+
color: var(--text);
|
|
490
|
+
display: flex;
|
|
491
|
+
align-items: center;
|
|
492
|
+
justify-content: center;
|
|
493
|
+
text-align: center;
|
|
494
|
+
padding: calc(env(safe-area-inset-top) + 24px) calc(env(safe-area-inset-right) + 32px) calc(env(safe-area-inset-bottom) + 24px) calc(env(safe-area-inset-left) + 32px);
|
|
495
|
+
-webkit-user-select: none;
|
|
496
|
+
user-select: none;
|
|
497
|
+
-webkit-tap-highlight-color: transparent;
|
|
498
|
+
}
|
|
499
|
+
main { max-width: 22rem; }
|
|
500
|
+
.app { font: -apple-system-subheadline; font-weight: 600; opacity: 0.6; margin-bottom: 0.5rem; }
|
|
501
|
+
h1 { font: -apple-system-title1; font-weight: 700; margin-bottom: 0.75rem; }
|
|
502
|
+
p { opacity: 0.72; line-height: 1.4; }
|
|
503
|
+
button {
|
|
504
|
+
font: -apple-system-headline;
|
|
505
|
+
margin-top: 1.75rem;
|
|
506
|
+
min-width: 9rem;
|
|
507
|
+
min-height: 44px;
|
|
508
|
+
padding: 0.75rem 1.75rem;
|
|
509
|
+
border: 0;
|
|
510
|
+
border-radius: 999px;
|
|
511
|
+
background: var(--text);
|
|
512
|
+
color: var(--background);
|
|
513
|
+
cursor: pointer;
|
|
514
|
+
}
|
|
515
|
+
button:active { opacity: 0.7; }
|
|
516
|
+
button:disabled { opacity: 0.5; }
|
|
517
|
+
</style>
|
|
518
|
+
</head>
|
|
519
|
+
<body>
|
|
520
|
+
<main>
|
|
521
|
+
<div class="app">${name}</div>
|
|
522
|
+
<h1>You're offline</h1>
|
|
523
|
+
<p>${name} could not reach the internet. Check your connection, then try again.</p>
|
|
524
|
+
<button id="retry" type="button">Retry</button>
|
|
525
|
+
</main>
|
|
526
|
+
<script>
|
|
527
|
+
(function () {
|
|
528
|
+
var button = document.getElementById('retry');
|
|
529
|
+
function retry() {
|
|
530
|
+
button.disabled = true;
|
|
531
|
+
button.textContent = 'Retrying';
|
|
532
|
+
var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;
|
|
533
|
+
if (handler) handler.postMessage({ action: 'retryRemote' });
|
|
534
|
+
else location.reload();
|
|
535
|
+
// Still here a while later means the page is still out of reach.
|
|
536
|
+
setTimeout(function () { button.disabled = false; button.textContent = 'Retry'; }, 12000);
|
|
537
|
+
}
|
|
538
|
+
button.addEventListener('click', retry);
|
|
539
|
+
})();
|
|
540
|
+
</script>
|
|
541
|
+
</body>
|
|
542
|
+
</html>
|
|
543
|
+
`;
|
|
381
544
|
}
|
|
382
545
|
function syncWebAssets(source, output) {
|
|
383
546
|
const sourcePath = resolve(source);
|
|
@@ -487,14 +650,15 @@ async function init(options) {
|
|
|
487
650
|
config.enableGeolocation = true;
|
|
488
651
|
writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
489
652
|
const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
|
|
490
|
-
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
653
|
+
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
|
|
491
654
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
492
655
|
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
493
656
|
cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
|
|
494
657
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
495
658
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
659
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
|
|
496
660
|
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>
|
|
661
|
+
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(/\{\{BACKGROUND_TASK_IDENTIFIERS\}\}/g, renderBackgroundTaskIdentifiers(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
662
|
<true/>
|
|
499
663
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
500
664
|
<true/>` : "");
|
|
@@ -584,45 +748,7 @@ ${nativeDependencies.join(`
|
|
|
584
748
|
writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
585
749
|
writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
586
750
|
}
|
|
587
|
-
|
|
588
|
-
<html>
|
|
589
|
-
<head>
|
|
590
|
-
<meta charset="UTF-8">
|
|
591
|
-
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
592
|
-
<title>${xmlEscape(displayName)}</title>
|
|
593
|
-
<style>
|
|
594
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
595
|
-
body {
|
|
596
|
-
font-family: -apple-system, system-ui, sans-serif;
|
|
597
|
-
background: ${config.backgroundColor};
|
|
598
|
-
color: white;
|
|
599
|
-
min-height: 100vh;
|
|
600
|
-
display: flex;
|
|
601
|
-
justify-content: center;
|
|
602
|
-
align-items: center;
|
|
603
|
-
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
604
|
-
}
|
|
605
|
-
.container { text-align: center; padding: 2rem; }
|
|
606
|
-
h1 { font-size: 2.5rem; margin-bottom: 1rem; }
|
|
607
|
-
p { opacity: 0.7; }
|
|
608
|
-
.ready { color: #4ade80; font-size: 0.9rem; margin-top: 2rem; }
|
|
609
|
-
</style>
|
|
610
|
-
</head>
|
|
611
|
-
<body>
|
|
612
|
-
<div class="container">
|
|
613
|
-
<h1>\u26A1 ${xmlEscape(displayName)}</h1>
|
|
614
|
-
<p>Built with Craft iOS</p>
|
|
615
|
-
<p class="ready" id="status">Waiting for Craft bridge...</p>
|
|
616
|
-
</div>
|
|
617
|
-
<script>
|
|
618
|
-
window.addEventListener('craftReady', (e) => {
|
|
619
|
-
document.getElementById('status').textContent = \`\u2713 Craft bridge ready (platform: \${e.detail.platform})\`;
|
|
620
|
-
console.log('Craft capabilities:', e.detail.capabilities);
|
|
621
|
-
});
|
|
622
|
-
</script>
|
|
623
|
-
</body>
|
|
624
|
-
</html>`;
|
|
625
|
-
writeFileSync(join(output, "dist", "index.html"), placeholderHtml);
|
|
751
|
+
writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
|
|
626
752
|
console.log("\u2705 Project initialized");
|
|
627
753
|
console.log("");
|
|
628
754
|
console.log("Next steps:");
|
package/dist/index.d.ts
CHANGED
|
@@ -78,13 +78,42 @@ export interface CraftConfig {
|
|
|
78
78
|
/**
|
|
79
79
|
* Also refuse to load anything but the app-bound domains in the web view
|
|
80
80
|
* (WKWebViewConfiguration.limitsNavigationsToAppBoundDomains). Off by
|
|
81
|
-
* default: links elsewhere already open in Safari
|
|
82
|
-
* maps in
|
|
81
|
+
* default: links elsewhere already open in Safari. WebKit applies the limit
|
|
82
|
+
* to the main frame only, so embedded players and maps in cross-origin
|
|
83
|
+
* iframes (YouTube, Vimeo) keep loading with it on (checked in the iOS 26
|
|
84
|
+
* simulator); WebKit only declines to inject user scripts into them, which
|
|
85
|
+
* the bridge never does.
|
|
83
86
|
*/
|
|
84
87
|
limitNavigationsToAppBoundDomains?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Universal links and shared web credentials
|
|
90
|
+
* (`com.apple.developer.associated-domains`). A bare host such as
|
|
91
|
+
* `hq.training` is written as `applinks:hq.training`; an entry with its own
|
|
92
|
+
* service (`webcredentials:`, `applinks:…?mode=developer`) is kept as given.
|
|
93
|
+
* A universal link reaches the page the way a custom-scheme link does, as
|
|
94
|
+
* `craftDeepLink`.
|
|
95
|
+
*/
|
|
85
96
|
associatedDomains?: string[];
|
|
97
|
+
/**
|
|
98
|
+
* iOS's scheduled background refresh (BGAppRefreshTask). When enabled the
|
|
99
|
+
* page gets `craftBackgroundRefresh` and answers with
|
|
100
|
+
* `craft.background.complete(ok)`. The identifier defaults to
|
|
101
|
+
* `<bundleId>.refresh` and is declared in BGTaskSchedulerPermittedIdentifiers,
|
|
102
|
+
* with the `fetch` background mode.
|
|
103
|
+
*/
|
|
104
|
+
backgroundRefresh?: CraftBackgroundRefresh;
|
|
86
105
|
appGroups?: string[];
|
|
87
106
|
appIconPath?: string;
|
|
107
|
+
/**
|
|
108
|
+
* The logo the launch screen shows on `backgroundColor`, and that stays on
|
|
109
|
+
* screen until the page says it is ready (`splash.hide()`), so the app goes
|
|
110
|
+
* from launch to content without a blank web view in between. PNG, PDF or
|
|
111
|
+
* SVG, drawn at its own size in points: give an SVG the width and height it
|
|
112
|
+
* should have on screen.
|
|
113
|
+
*/
|
|
114
|
+
splashImagePath?: string;
|
|
115
|
+
/** The splash logo in Dark Mode, on `backgroundColorDark`. The light one if unset. */
|
|
116
|
+
splashImagePathDark?: string;
|
|
88
117
|
/**
|
|
89
118
|
* Whether the app uses encryption beyond the exempt kinds (HTTPS, the
|
|
90
119
|
* system's own). Written as `ITSAppUsesNonExemptEncryption`, so App Store
|
|
@@ -93,9 +122,36 @@ export interface CraftConfig {
|
|
|
93
122
|
*/
|
|
94
123
|
usesNonExemptEncryption?: boolean;
|
|
95
124
|
privacy?: CraftPrivacyManifest;
|
|
125
|
+
/** Press-and-hold link previews, as in Safari. Default false. */
|
|
126
|
+
allowsLinkPreview?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* WebKit's bar above the keyboard for a form field (previous, next, Done),
|
|
129
|
+
* which no native app's keyboard has. Default false: hidden. A page can
|
|
130
|
+
* change it with `craft.chrome.setKeyboardAccessory()`.
|
|
131
|
+
*/
|
|
132
|
+
keyboardAccessory?: boolean;
|
|
133
|
+
/** Pinch and double-tap zoom off, and the page's viewport pinned at scale 1. Default true. */
|
|
134
|
+
disableZoom?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* The longest the launch splash stays up, in seconds, when the page neither
|
|
137
|
+
* hides it (`splash.hide()`) nor paints. Default 3.
|
|
138
|
+
*/
|
|
139
|
+
splashMaxSeconds?: number;
|
|
140
|
+
/**
|
|
141
|
+
* How long the app's own page may take to answer, in seconds, before the
|
|
142
|
+
* load counts as unreachable and the bundled page stands in. Default 10.
|
|
143
|
+
*/
|
|
144
|
+
requestTimeoutSeconds?: number;
|
|
96
145
|
orientations?: Array<'portrait' | 'landscape-left' | 'landscape-right' | 'portrait-upside-down'>;
|
|
97
146
|
deviceFamilies?: Array<'iphone' | 'ipad'>;
|
|
98
147
|
}
|
|
148
|
+
export interface CraftBackgroundRefresh {
|
|
149
|
+
enabled: boolean;
|
|
150
|
+
/** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
|
|
151
|
+
identifier?: string;
|
|
152
|
+
/** The soonest iOS may run it again, in minutes. Default 15. */
|
|
153
|
+
minimumIntervalMinutes?: number;
|
|
154
|
+
}
|
|
99
155
|
export interface CraftPrivacyDataType {
|
|
100
156
|
type: string;
|
|
101
157
|
linked?: boolean;
|
|
@@ -175,6 +231,16 @@ export declare function renderOrientations(config: CraftConfig): string;
|
|
|
175
231
|
export declare function renderAppBoundDomains(config: CraftConfig): string;
|
|
176
232
|
export declare function renderUrlTypes(config: CraftConfig): string;
|
|
177
233
|
export declare function renderBackgroundModes(config: CraftConfig): string;
|
|
234
|
+
/** The background refresh task's identifier, or null when it is off. */
|
|
235
|
+
export declare function backgroundRefreshIdentifier(config: Pick<CraftConfig, 'backgroundRefresh' | 'bundleId'>): string | null;
|
|
236
|
+
/** BGTaskSchedulerPermittedIdentifiers: iOS runs no task it does not list. */
|
|
237
|
+
export declare function renderBackgroundTaskIdentifiers(config: Pick<CraftConfig, 'backgroundRefresh' | 'bundleId'>): string;
|
|
238
|
+
/**
|
|
239
|
+
* The associated-domains entitlement's entries. Each needs a service prefix,
|
|
240
|
+
* and a bare host (the easy thing to write) used to be signed as it was and
|
|
241
|
+
* then matched nothing, so universal links silently never opened the app.
|
|
242
|
+
*/
|
|
243
|
+
export declare function associatedDomainEntries(domains: string[] | undefined): string[];
|
|
178
244
|
export declare function renderEntitlements(config: CraftConfig): string;
|
|
179
245
|
export declare function renderWatchEntitlements(config: CraftConfig): string;
|
|
180
246
|
/**
|
|
@@ -205,6 +271,28 @@ export declare function renderPrivacyManifest(config: CraftConfig): string;
|
|
|
205
271
|
* (grey + alpha) or 6 (RGB + alpha), or a tRNS chunk before the image data.
|
|
206
272
|
*/
|
|
207
273
|
export declare function pngHasAlpha(bytes: Uint8Array): boolean;
|
|
274
|
+
/** The asset the launch screen and the splash both draw, so one hands over to the other unseen. */
|
|
275
|
+
export declare const LAUNCH_LOGO = "LaunchLogo";
|
|
276
|
+
/** The launch screen: the background colour, and the splash logo on it when there is one. */
|
|
277
|
+
export declare function renderLaunchScreen(config: Pick<CraftConfig, 'splashImagePath'>): string;
|
|
278
|
+
/** Near-black or near-white, whichever reads on `background` (WCAG relative luminance). */
|
|
279
|
+
export declare function readableTextOn(background: string | undefined): string;
|
|
280
|
+
/**
|
|
281
|
+
* The page the app shows when its own page cannot be reached: the generated
|
|
282
|
+
* `dist/index.html` until the app's web assets replace it.
|
|
283
|
+
*
|
|
284
|
+
* An app whose page has a service worker normally opens from it offline, so
|
|
285
|
+
* this only shows on a first launch without one, or when the remote fails
|
|
286
|
+
* hard. It used to be a placeholder that said "Built with Craft iOS" in white
|
|
287
|
+
* on whatever the background was (invisible on a light one) with a bridge
|
|
288
|
+
* status line under it: debug output, in front of the person using the app.
|
|
289
|
+
*
|
|
290
|
+
* Now it says what happened in the app's own colours, light and dark, sized
|
|
291
|
+
* with Dynamic Type, and Retry asks the shell to load the app's page again.
|
|
292
|
+
* The shell also goes back to the app's page by itself when the connection
|
|
293
|
+
* returns, so the page does not retry on `online` as well.
|
|
294
|
+
*/
|
|
295
|
+
export declare function renderOfflinePage(config: Pick<CraftConfig, 'appName' | 'backgroundColor' | 'backgroundColorDark'>): string;
|
|
208
296
|
/** Replace the bundled web application atomically so removed assets cannot linger. */
|
|
209
297
|
export declare function syncWebAssets(source: string, output: string): void;
|
|
210
298
|
/**
|