@craft-native/ios 0.0.123 → 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 +129 -43
- package/dist/index.d.ts +76 -2
- package/dist/index.js +134 -43
- package/package.json +1 -1
- package/templates/CraftApp.swift +1086 -67
- 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 +2 -1
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) {
|
|
@@ -419,6 +446,102 @@ function renderLaunchScreen(config) {
|
|
|
419
446
|
return ` <key>UIColorName</key>
|
|
420
447
|
<string>LaunchBackground</string>${image}`;
|
|
421
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
|
+
`;
|
|
544
|
+
}
|
|
422
545
|
function syncWebAssets(source, output) {
|
|
423
546
|
const sourcePath = resolve(source);
|
|
424
547
|
if (!existsSync(sourcePath))
|
|
@@ -527,14 +650,15 @@ async function init(options) {
|
|
|
527
650
|
config.enableGeolocation = true;
|
|
528
651
|
writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
529
652
|
const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
|
|
530
|
-
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);
|
|
531
654
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
532
655
|
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
533
656
|
cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
|
|
534
657
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
535
658
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
659
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
|
|
536
660
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
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>
|
|
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>
|
|
538
662
|
<true/>
|
|
539
663
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
540
664
|
<true/>` : "");
|
|
@@ -624,45 +748,7 @@ ${nativeDependencies.join(`
|
|
|
624
748
|
writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
625
749
|
writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
626
750
|
}
|
|
627
|
-
|
|
628
|
-
<html>
|
|
629
|
-
<head>
|
|
630
|
-
<meta charset="UTF-8">
|
|
631
|
-
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
632
|
-
<title>${xmlEscape(displayName)}</title>
|
|
633
|
-
<style>
|
|
634
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
635
|
-
body {
|
|
636
|
-
font-family: -apple-system, system-ui, sans-serif;
|
|
637
|
-
background: ${config.backgroundColor};
|
|
638
|
-
color: white;
|
|
639
|
-
min-height: 100vh;
|
|
640
|
-
display: flex;
|
|
641
|
-
justify-content: center;
|
|
642
|
-
align-items: center;
|
|
643
|
-
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
644
|
-
}
|
|
645
|
-
.container { text-align: center; padding: 2rem; }
|
|
646
|
-
h1 { font-size: 2.5rem; margin-bottom: 1rem; }
|
|
647
|
-
p { opacity: 0.7; }
|
|
648
|
-
.ready { color: #4ade80; font-size: 0.9rem; margin-top: 2rem; }
|
|
649
|
-
</style>
|
|
650
|
-
</head>
|
|
651
|
-
<body>
|
|
652
|
-
<div class="container">
|
|
653
|
-
<h1>\u26A1 ${xmlEscape(displayName)}</h1>
|
|
654
|
-
<p>Built with Craft iOS</p>
|
|
655
|
-
<p class="ready" id="status">Waiting for Craft bridge...</p>
|
|
656
|
-
</div>
|
|
657
|
-
<script>
|
|
658
|
-
window.addEventListener('craftReady', (e) => {
|
|
659
|
-
document.getElementById('status').textContent = \`\u2713 Craft bridge ready (platform: \${e.detail.platform})\`;
|
|
660
|
-
console.log('Craft capabilities:', e.detail.capabilities);
|
|
661
|
-
});
|
|
662
|
-
</script>
|
|
663
|
-
</body>
|
|
664
|
-
</html>`;
|
|
665
|
-
writeFileSync(join(output, "dist", "index.html"), placeholderHtml);
|
|
751
|
+
writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
|
|
666
752
|
console.log("\u2705 Project initialized");
|
|
667
753
|
console.log("");
|
|
668
754
|
console.log("Next steps:");
|
package/dist/index.d.ts
CHANGED
|
@@ -78,11 +78,30 @@ 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;
|
|
88
107
|
/**
|
|
@@ -103,9 +122,36 @@ export interface CraftConfig {
|
|
|
103
122
|
*/
|
|
104
123
|
usesNonExemptEncryption?: boolean;
|
|
105
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;
|
|
106
145
|
orientations?: Array<'portrait' | 'landscape-left' | 'landscape-right' | 'portrait-upside-down'>;
|
|
107
146
|
deviceFamilies?: Array<'iphone' | 'ipad'>;
|
|
108
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
|
+
}
|
|
109
155
|
export interface CraftPrivacyDataType {
|
|
110
156
|
type: string;
|
|
111
157
|
linked?: boolean;
|
|
@@ -185,6 +231,16 @@ export declare function renderOrientations(config: CraftConfig): string;
|
|
|
185
231
|
export declare function renderAppBoundDomains(config: CraftConfig): string;
|
|
186
232
|
export declare function renderUrlTypes(config: CraftConfig): string;
|
|
187
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[];
|
|
188
244
|
export declare function renderEntitlements(config: CraftConfig): string;
|
|
189
245
|
export declare function renderWatchEntitlements(config: CraftConfig): string;
|
|
190
246
|
/**
|
|
@@ -219,6 +275,24 @@ export declare function pngHasAlpha(bytes: Uint8Array): boolean;
|
|
|
219
275
|
export declare const LAUNCH_LOGO = "LaunchLogo";
|
|
220
276
|
/** The launch screen: the background colour, and the splash logo on it when there is one. */
|
|
221
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;
|
|
222
296
|
/** Replace the bundled web application atomically so removed assets cannot linger. */
|
|
223
297
|
export declare function syncWebAssets(source: string, output: string): void;
|
|
224
298
|
/**
|