@craft-native/ios 0.0.123 → 0.0.125
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 +129 -5
- package/dist/cli.js +216 -48
- package/dist/index.d.ts +125 -3
- package/dist/index.js +284 -47
- package/package.json +1 -1
- package/templates/CraftApp.swift +1186 -69
- package/templates/CraftHybrid.swift +1196 -0
- package/templates/CraftNativeActions.swift +310 -10
- package/templates/CraftNativeFlatList.swift +226 -17
- package/templates/CraftNativeScreen.swift +2217 -220
- package/templates/CraftNativeUI.swift +562 -0
- package/templates/Info.plist.template +2 -1
package/README.md
CHANGED
|
@@ -64,6 +64,12 @@ 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
|
+
`Pressable`, `TouchableOpacity`, and `TouchableHighlight` render as native
|
|
68
|
+
pressable flow containers. `Modal` renders a parent-scoped blocking layer in native mode, with `visible`,
|
|
69
|
+
`transparent`, `animationType`, `onShow`, `onDismiss`, and `onRequestClose`
|
|
70
|
+
support. `FlatList` item roots receive the same `onLayout` callback as ordinary native
|
|
71
|
+
views when rows are materialized or recycled.
|
|
72
|
+
|
|
67
73
|
```bash
|
|
68
74
|
bun add craft-native
|
|
69
75
|
```
|
|
@@ -106,28 +112,36 @@ In Xcode:
|
|
|
106
112
|
|
|
107
113
|
### Experimental WebView-free screen
|
|
108
114
|
|
|
109
|
-
|
|
115
|
+
Keyboard frame changes update the native controller's additional safe area, so
|
|
116
|
+
focused inputs and responsive `onLayout` measurements move with the keyboard.
|
|
117
|
+
|
|
118
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `TextInput`, `Picker`, `Switch`, `Slider`, `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. `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `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
119
|
|
|
111
120
|
```bash
|
|
112
121
|
craft ios init NativeDemo --renderer native --output ./ios-native
|
|
113
|
-
stx
|
|
122
|
+
stx native compile Screen.stx --format bundle --output ./screen.js
|
|
114
123
|
craft ios build --output ./ios-native --native-bundle ./screen.js
|
|
115
124
|
craft ios run --output ./ios-native --simulator
|
|
116
125
|
```
|
|
117
126
|
|
|
118
|
-
`stx
|
|
127
|
+
`stx native compile` is part of the [stx](https://github.com/stacksjs/stx) CLI (`@stacksjs/stx`); build tools can call `compileNativeBundle` from `@stacksjs/stx/native` instead. A runnable screen is in [`fixtures/native-screen/Screen.stx`](./fixtures/native-screen/Screen.stx). In native mode, the app never instantiates `WKWebView`; a missing `dist/native-screen.js` fails the build rather than silently opening the web placeholder.
|
|
119
128
|
|
|
120
129
|
Native screen updates use the versioned mutation protocol when the bundle
|
|
121
130
|
supports it and fall back to whole-document rendering for older bundles.
|
|
122
131
|
Updates reconcile the UIKit tree by sibling-scoped `key` (or `testID` for
|
|
123
132
|
existing screens); unkeyed children reuse their position. `TextInput` objects
|
|
124
133
|
therefore keep focus and selection while unrelated labels and buttons change.
|
|
134
|
+
Native `TextInput` also applies `placeholderTextColor`, `selectionColor`,
|
|
135
|
+
positive `maxLength`, `multiline`, and `numberOfLines` values while preserving
|
|
136
|
+
the WebView fallback for routes that do not opt into the renderer.
|
|
125
137
|
The protocol wire format, atomic validation, revisions, and error codes are in
|
|
126
138
|
the [Native STX guide](../../docs/guides/native-stx.md). On macOS,
|
|
127
139
|
`bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and
|
|
128
140
|
simulator UI tests.
|
|
129
141
|
|
|
130
|
-
For multiple native screens, configure named `.stx` routes and an initial screen in `
|
|
142
|
+
For multiple native screens, configure named `.stx` routes and an initial screen in `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.
|
|
143
|
+
|
|
144
|
+
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.
|
|
131
145
|
|
|
132
146
|
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
147
|
|
|
@@ -203,12 +217,64 @@ development environment and Release builds use the production environment.
|
|
|
203
217
|
The generated entitlements select the correct value from the active Xcode
|
|
204
218
|
build configuration automatically.
|
|
205
219
|
|
|
220
|
+
### Launch, offline and the feel of the web view
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"splashMaxSeconds": 3,
|
|
225
|
+
"requestTimeoutSeconds": 10,
|
|
226
|
+
"allowsLinkPreview": false,
|
|
227
|
+
"keyboardAccessory": false,
|
|
228
|
+
"disableZoom": true,
|
|
229
|
+
"associatedDomains": ["hq.training"],
|
|
230
|
+
"backgroundRefresh": { "enabled": true, "minimumIntervalMinutes": 30 }
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- `splashMaxSeconds` (default 3): the splash stays until the page calls
|
|
235
|
+
`splash.hide()` or its first contentful paint is on screen, and never longer
|
|
236
|
+
than this.
|
|
237
|
+
- `requestTimeoutSeconds` (default 10): how long the app's own page may take
|
|
238
|
+
before the load counts as unreachable and the bundled page stands in.
|
|
239
|
+
- The bundled page `init` writes (`dist/index.html`, until your web assets
|
|
240
|
+
replace it) is an offline page in the app's colours, light and dark, whose
|
|
241
|
+
Retry loads the remote page again. An app whose page has a service worker
|
|
242
|
+
normally opens from it offline; this shows on a first launch without one or
|
|
243
|
+
after a hard failure. With `appBoundDomains` it is loaded as a page of the
|
|
244
|
+
remote origin, because WebKit refuses to navigate from an app-bound domain
|
|
245
|
+
to `craft://app`.
|
|
246
|
+
- `allowsLinkPreview` (default false): Safari's press-and-hold link previews.
|
|
247
|
+
- `keyboardAccessory` (default false): WebKit's previous/next/Done bar above
|
|
248
|
+
the keyboard. Change it at runtime with `craft.chrome.setKeyboardAccessory`.
|
|
249
|
+
- `disableZoom` (default true): no pinch or double-tap zoom, and the page's
|
|
250
|
+
viewport pinned at scale 1.
|
|
251
|
+
- `associatedDomains`: universal links. A bare host is signed as
|
|
252
|
+
`applinks:<host>`; a universal link reaches the page as `craftDeepLink`, like
|
|
253
|
+
a custom-scheme link.
|
|
254
|
+
- `backgroundRefresh`: a BGAppRefreshTask (`<bundleId>.refresh` unless
|
|
255
|
+
`identifier` is set), declared in `BGTaskSchedulerPermittedIdentifiers` with
|
|
256
|
+
the `fetch` background mode, scheduled no sooner than
|
|
257
|
+
`minimumIntervalMinutes` (default 15). It fires `craftBackgroundRefresh`.
|
|
258
|
+
|
|
259
|
+
The web view also: shows `alert`, `confirm` and `prompt` as system alerts;
|
|
260
|
+
opens `target=_blank` and `window.open` for the app's own origins in place and
|
|
261
|
+
anything else in `SFSafariViewController`; grants `getUserMedia` to the app's
|
|
262
|
+
own origin without WebKit's extra prompt; dismisses the keyboard with a drag;
|
|
263
|
+
allows picture in picture; ends its user agent with `Craft/<version>
|
|
264
|
+
<AppName>/<version>`; brings a page whose content process died back under the
|
|
265
|
+
splash, on the page it was showing, at most three times a minute before
|
|
266
|
+
showing the offline page; and saves its `interactionState` when the app goes to
|
|
267
|
+
the background, so a launch after iOS ended the app returns to the same screen
|
|
268
|
+
(same build only).
|
|
269
|
+
|
|
206
270
|
## JavaScript Bridge
|
|
207
271
|
|
|
208
272
|
Once Craft is initialized, the `window.craft` object is available:
|
|
209
273
|
|
|
210
274
|
```javascript
|
|
211
|
-
//
|
|
275
|
+
// window.craft is installed at document start, before the page's own scripts,
|
|
276
|
+
// so it can be used at once. craftReady still fires, once, when the document
|
|
277
|
+
// has been parsed, for pages that wait for it.
|
|
212
278
|
window.addEventListener('craftReady', (e) => {
|
|
213
279
|
console.log('Platform:', e.detail.platform); // 'ios'
|
|
214
280
|
console.log('Capabilities:', e.detail.capabilities);
|
|
@@ -600,6 +666,64 @@ window.craft.watch.onReachabilityChange((status) => {
|
|
|
600
666
|
});
|
|
601
667
|
```
|
|
602
668
|
|
|
669
|
+
### Native UI, appearance and background (iOS)
|
|
670
|
+
|
|
671
|
+
Every call below returns a promise that settles and never throws: a failure
|
|
672
|
+
answers with what "nothing happened" means for it. `craft-native/mobile`
|
|
673
|
+
exports the same calls typed (`dialog`, `contextMenu`, `browser`, `symbols`,
|
|
674
|
+
`statusBar`, `chrome`, `refresh`, `background`, `appearance`), resolving
|
|
675
|
+
outside the shell too.
|
|
676
|
+
|
|
677
|
+
```javascript
|
|
678
|
+
// Haptics by kind, each on UIKit's own generator, kept warm.
|
|
679
|
+
await craft.haptics.impact('rigid'); // light, medium, heavy, soft, rigid
|
|
680
|
+
await craft.haptics.notification('success'); // success, warning, error
|
|
681
|
+
await craft.haptics.selection();
|
|
682
|
+
await craft.haptics.prepare('selection'); // before a gesture that will play one
|
|
683
|
+
|
|
684
|
+
// System dialogs. An anchor is a rect in CSS pixels or an element.
|
|
685
|
+
await craft.dialog.alert({ title: 'Saved' });
|
|
686
|
+
const ok = await craft.dialog.confirm({ title: 'Delete workout?', confirmLabel: 'Delete', destructive: true });
|
|
687
|
+
const choice = await craft.dialog.actionSheet({
|
|
688
|
+
title: 'Workout',
|
|
689
|
+
anchor: button,
|
|
690
|
+
actions: [{ id: 'edit', title: 'Edit' }, { id: 'delete', title: 'Delete', style: 'destructive' }],
|
|
691
|
+
}); // the id, or null when dismissed
|
|
692
|
+
|
|
693
|
+
// The system context menu (iOS 17.4+; the edit menu, then an action sheet, before).
|
|
694
|
+
const picked = await craft.contextMenu.show({
|
|
695
|
+
anchor: row,
|
|
696
|
+
items: [{ id: 'duplicate', title: 'Duplicate', symbol: 'plus.square.on.square' }, { id: 'delete', title: 'Delete', symbol: 'trash', destructive: true }],
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// SFSafariViewController, or the system web sign-in sheet.
|
|
700
|
+
await craft.browser.open('https://example.com');
|
|
701
|
+
const { url, cancelled } = await craft.browser.open(authorizeURL, { mode: 'auth', callbackScheme: 'hqtraining' });
|
|
702
|
+
|
|
703
|
+
// An SF Symbol as a PNG data URL.
|
|
704
|
+
img.src = await craft.symbols.image('heart.fill', { pointSize: 22, weight: 'semibold', color: '#e11d48' });
|
|
705
|
+
|
|
706
|
+
// Chrome around the page.
|
|
707
|
+
await craft.statusBar.setStyle('light'); // default, light, dark
|
|
708
|
+
await craft.chrome.setUnderPageColor('#020617'); // shown past the page's edges
|
|
709
|
+
await craft.chrome.setKeyboardAccessory(true);
|
|
710
|
+
|
|
711
|
+
// Native pull to refresh.
|
|
712
|
+
await craft.refresh.enable({ tintColor: '#22c55e' });
|
|
713
|
+
craft.refresh.onRefresh(async () => { await reload(); craft.refresh.end(); });
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
Events on `window`:
|
|
717
|
+
|
|
718
|
+
| Event | Detail | When |
|
|
719
|
+
| --- | --- | --- |
|
|
720
|
+
| `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. |
|
|
721
|
+
| `craftResume` | `{ backgroundedMs }` | the app returned to the foreground |
|
|
722
|
+
| `craftMemoryWarning` | `{}` | iOS is short of memory |
|
|
723
|
+
| `craftSilentPush` | `{ id, payload }` | a `content-available` push; answer with `craft.background.complete(ok, id)` within 25 s |
|
|
724
|
+
| `craftBackgroundRefresh` | `{ id }` | the scheduled refresh ran; answer the same way |
|
|
725
|
+
| `craftRefresh` | `{}` | the native pull-to-refresh was pulled |
|
|
726
|
+
|
|
603
727
|
## CLI Reference
|
|
604
728
|
|
|
605
729
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,69 @@ 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
|
+
})();
|
|
19
|
+
function normalizeNativePath(raw) {
|
|
20
|
+
if (typeof raw !== "string")
|
|
21
|
+
return null;
|
|
22
|
+
let path = raw;
|
|
23
|
+
const cut = path.search(/[?#]/);
|
|
24
|
+
if (cut >= 0)
|
|
25
|
+
path = path.slice(0, cut);
|
|
26
|
+
if (!path.startsWith("/"))
|
|
27
|
+
return null;
|
|
28
|
+
path = path.replace(/\/{2,}/g, "/");
|
|
29
|
+
if (path.length > 1 && path.endsWith("/"))
|
|
30
|
+
path = path.slice(0, -1);
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
function routeSegments(path) {
|
|
34
|
+
return path === "/" ? [] : path.slice(1).split("/");
|
|
35
|
+
}
|
|
36
|
+
function isHybrid(config) {
|
|
37
|
+
return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
38
|
+
}
|
|
39
|
+
function hybridConfigProblems(config) {
|
|
40
|
+
const problems = [];
|
|
41
|
+
const screens = Object.entries(config.nativeScreens ?? {});
|
|
42
|
+
if (screens.length && config.renderer === "native")
|
|
43
|
+
problems.push('nativeScreens mixes native screens into a web app; it needs renderer: "web" (renderer: "native" is all native already).');
|
|
44
|
+
for (const [pattern, screen] of screens) {
|
|
45
|
+
if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
|
|
46
|
+
problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
|
|
47
|
+
if (routeSegments(normalizeNativePath(pattern) ?? "/").slice(0, -1).includes("*"))
|
|
48
|
+
problems.push(`nativeScreens path "${pattern}" may only end in *.`);
|
|
49
|
+
if (typeof screen !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(screen))
|
|
50
|
+
problems.push(`nativeScreens screen "${String(screen)}" for "${pattern}" must be a screen name from the native bundle.`);
|
|
51
|
+
}
|
|
52
|
+
const ids = new Set;
|
|
53
|
+
for (const tab of config.tabs ?? []) {
|
|
54
|
+
if (!tab || typeof tab.id !== "string" || !tab.id || typeof tab.title !== "string" || !tab.title)
|
|
55
|
+
problems.push("Each tab needs an id (its root path) and a title.");
|
|
56
|
+
else if (ids.has(tab.id))
|
|
57
|
+
problems.push(`Tab "${tab.id}" is listed twice.`);
|
|
58
|
+
else
|
|
59
|
+
ids.add(tab.id);
|
|
60
|
+
}
|
|
61
|
+
for (const [key, target] of Object.entries(config.shareStorage ?? {})) {
|
|
62
|
+
if (!key || typeof target !== "string" || !target)
|
|
63
|
+
problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
|
|
64
|
+
}
|
|
65
|
+
return problems;
|
|
66
|
+
}
|
|
67
|
+
function installNativeBundle(source, output) {
|
|
68
|
+
if (!existsSync(source))
|
|
69
|
+
throw new Error(`Native bundle not found: ${source}`);
|
|
70
|
+
const destination = join(output, "dist", "native-screen.js");
|
|
71
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
72
|
+
if (resolve(source) !== resolve(destination))
|
|
73
|
+
cpSync(source, destination);
|
|
74
|
+
}
|
|
12
75
|
var DEFAULT_CONFIG = {
|
|
13
76
|
renderer: "web",
|
|
14
77
|
version: "1.0.0",
|
|
@@ -176,6 +239,8 @@ function plistArray(values, indent = 2) {
|
|
|
176
239
|
}
|
|
177
240
|
function renderBackgroundModes(config) {
|
|
178
241
|
const modes = new Set;
|
|
242
|
+
if (config.backgroundRefresh?.enabled)
|
|
243
|
+
modes.add("fetch");
|
|
179
244
|
if (config.enableBackgroundLocation)
|
|
180
245
|
modes.add("location");
|
|
181
246
|
if (config.enableBackgroundTasks)
|
|
@@ -189,12 +254,30 @@ function renderBackgroundModes(config) {
|
|
|
189
254
|
${plistArray([...modes])}
|
|
190
255
|
</array>`;
|
|
191
256
|
}
|
|
257
|
+
function backgroundRefreshIdentifier(config) {
|
|
258
|
+
if (!config.backgroundRefresh?.enabled)
|
|
259
|
+
return null;
|
|
260
|
+
return config.backgroundRefresh.identifier?.trim() || `${config.bundleId}.refresh`;
|
|
261
|
+
}
|
|
262
|
+
function renderBackgroundTaskIdentifiers(config) {
|
|
263
|
+
const identifier = backgroundRefreshIdentifier(config);
|
|
264
|
+
if (!identifier)
|
|
265
|
+
return "";
|
|
266
|
+
return ` <key>BGTaskSchedulerPermittedIdentifiers</key>
|
|
267
|
+
<array>
|
|
268
|
+
${plistArray([identifier])}
|
|
269
|
+
</array>`;
|
|
270
|
+
}
|
|
271
|
+
function associatedDomainEntries(domains) {
|
|
272
|
+
return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
|
|
273
|
+
}
|
|
192
274
|
function renderEntitlements(config) {
|
|
193
275
|
const entries = [];
|
|
194
|
-
|
|
276
|
+
const associatedDomains = associatedDomainEntries(config.associatedDomains);
|
|
277
|
+
if (associatedDomains.length) {
|
|
195
278
|
entries.push(` <key>com.apple.developer.associated-domains</key>
|
|
196
279
|
<array>
|
|
197
|
-
${plistArray(
|
|
280
|
+
${plistArray(associatedDomains)}
|
|
198
281
|
</array>`);
|
|
199
282
|
}
|
|
200
283
|
if (config.appGroups?.length) {
|
|
@@ -419,6 +502,102 @@ function renderLaunchScreen(config) {
|
|
|
419
502
|
return ` <key>UIColorName</key>
|
|
420
503
|
<string>LaunchBackground</string>${image}`;
|
|
421
504
|
}
|
|
505
|
+
function hexChannels(hex) {
|
|
506
|
+
const value = hex?.trim().replace(/^#/, "") ?? "";
|
|
507
|
+
const full = value.length === 3 ? [...value].map((digit) => digit + digit).join("") : value.slice(0, 6);
|
|
508
|
+
if (!/^[0-9a-f]{6}$/i.test(full))
|
|
509
|
+
return null;
|
|
510
|
+
return [0, 2, 4].map((index) => Number.parseInt(full.slice(index, index + 2), 16));
|
|
511
|
+
}
|
|
512
|
+
function readableTextOn(background) {
|
|
513
|
+
const channels = hexChannels(background);
|
|
514
|
+
if (!channels)
|
|
515
|
+
return "#0f172a";
|
|
516
|
+
const [r, g, b] = channels.map((channel) => {
|
|
517
|
+
const c = channel / 255;
|
|
518
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
519
|
+
});
|
|
520
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
521
|
+
return luminance > 0.4 ? "#0f172a" : "#f8fafc";
|
|
522
|
+
}
|
|
523
|
+
function renderOfflinePage(config) {
|
|
524
|
+
const light = hexChannels(config.backgroundColor) ? config.backgroundColor : "#ffffff";
|
|
525
|
+
const dark = hexChannels(config.backgroundColorDark) ? config.backgroundColorDark : light;
|
|
526
|
+
const name = xmlEscape(config.appName);
|
|
527
|
+
return `<!DOCTYPE html>
|
|
528
|
+
<html lang="en">
|
|
529
|
+
<head>
|
|
530
|
+
<meta charset="UTF-8">
|
|
531
|
+
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
532
|
+
<meta name="color-scheme" content="light dark">
|
|
533
|
+
<title>${name}</title>
|
|
534
|
+
<style>
|
|
535
|
+
:root { --background: ${light}; --text: ${readableTextOn(light)}; }
|
|
536
|
+
@media (prefers-color-scheme: dark) {
|
|
537
|
+
:root { --background: ${dark}; --text: ${readableTextOn(dark)}; }
|
|
538
|
+
}
|
|
539
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
540
|
+
html, body { height: 100%; }
|
|
541
|
+
body {
|
|
542
|
+
font: -apple-system-body;
|
|
543
|
+
font-family: -apple-system, system-ui, sans-serif;
|
|
544
|
+
background: var(--background);
|
|
545
|
+
color: var(--text);
|
|
546
|
+
display: flex;
|
|
547
|
+
align-items: center;
|
|
548
|
+
justify-content: center;
|
|
549
|
+
text-align: center;
|
|
550
|
+
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);
|
|
551
|
+
-webkit-user-select: none;
|
|
552
|
+
user-select: none;
|
|
553
|
+
-webkit-tap-highlight-color: transparent;
|
|
554
|
+
}
|
|
555
|
+
main { max-width: 22rem; }
|
|
556
|
+
.app { font: -apple-system-subheadline; font-weight: 600; opacity: 0.6; margin-bottom: 0.5rem; }
|
|
557
|
+
h1 { font: -apple-system-title1; font-weight: 700; margin-bottom: 0.75rem; }
|
|
558
|
+
p { opacity: 0.72; line-height: 1.4; }
|
|
559
|
+
button {
|
|
560
|
+
font: -apple-system-headline;
|
|
561
|
+
margin-top: 1.75rem;
|
|
562
|
+
min-width: 9rem;
|
|
563
|
+
min-height: 44px;
|
|
564
|
+
padding: 0.75rem 1.75rem;
|
|
565
|
+
border: 0;
|
|
566
|
+
border-radius: 999px;
|
|
567
|
+
background: var(--text);
|
|
568
|
+
color: var(--background);
|
|
569
|
+
cursor: pointer;
|
|
570
|
+
}
|
|
571
|
+
button:active { opacity: 0.7; }
|
|
572
|
+
button:disabled { opacity: 0.5; }
|
|
573
|
+
</style>
|
|
574
|
+
</head>
|
|
575
|
+
<body>
|
|
576
|
+
<main>
|
|
577
|
+
<div class="app">${name}</div>
|
|
578
|
+
<h1>You're offline</h1>
|
|
579
|
+
<p>${name} could not reach the internet. Check your connection, then try again.</p>
|
|
580
|
+
<button id="retry" type="button">Retry</button>
|
|
581
|
+
</main>
|
|
582
|
+
<script>
|
|
583
|
+
(function () {
|
|
584
|
+
var button = document.getElementById('retry');
|
|
585
|
+
function retry() {
|
|
586
|
+
button.disabled = true;
|
|
587
|
+
button.textContent = 'Retrying';
|
|
588
|
+
var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;
|
|
589
|
+
if (handler) handler.postMessage({ action: 'retryRemote' });
|
|
590
|
+
else location.reload();
|
|
591
|
+
// Still here a while later means the page is still out of reach.
|
|
592
|
+
setTimeout(function () { button.disabled = false; button.textContent = 'Retry'; }, 12000);
|
|
593
|
+
}
|
|
594
|
+
button.addEventListener('click', retry);
|
|
595
|
+
})();
|
|
596
|
+
</script>
|
|
597
|
+
</body>
|
|
598
|
+
</html>
|
|
599
|
+
`;
|
|
600
|
+
}
|
|
422
601
|
function syncWebAssets(source, output) {
|
|
423
602
|
const sourcePath = resolve(source);
|
|
424
603
|
if (!existsSync(sourcePath))
|
|
@@ -525,16 +704,24 @@ async function init(options) {
|
|
|
525
704
|
}
|
|
526
705
|
if (config.enableBackgroundLocation)
|
|
527
706
|
config.enableGeolocation = true;
|
|
707
|
+
const problems = hybridConfigProblems(config);
|
|
708
|
+
if (problems.length)
|
|
709
|
+
throw new Error(problems.join(`
|
|
710
|
+
`));
|
|
711
|
+
if (Object.keys(config.shareStorage ?? {}).length)
|
|
712
|
+
config.enableSecureStorage = true;
|
|
528
713
|
writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
529
714
|
const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
|
|
530
|
-
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
715
|
+
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
|
|
531
716
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
532
717
|
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
533
718
|
cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
|
|
534
719
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
535
720
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
721
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
|
|
722
|
+
cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
|
|
536
723
|
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>
|
|
724
|
+
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
725
|
<true/>
|
|
539
726
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
540
727
|
<true/>` : "");
|
|
@@ -624,51 +811,19 @@ ${nativeDependencies.join(`
|
|
|
624
811
|
writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
625
812
|
writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
626
813
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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);
|
|
814
|
+
writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
|
|
815
|
+
if (isHybrid(config)) {
|
|
816
|
+
if (config.nativeBundle)
|
|
817
|
+
installNativeBundle(config.nativeBundle, output);
|
|
818
|
+
else
|
|
819
|
+
console.warn(" \u26A0 nativeScreens is set without nativeBundle: every path opens in the web view until `craft ios build --native-bundle` supplies one.");
|
|
820
|
+
}
|
|
666
821
|
console.log("\u2705 Project initialized");
|
|
667
822
|
console.log("");
|
|
668
823
|
console.log("Next steps:");
|
|
669
824
|
console.log(` 1. cd ${output}`);
|
|
670
825
|
if (config.renderer === "native") {
|
|
671
|
-
console.log(" 2. Compile your screen: stx
|
|
826
|
+
console.log(" 2. Compile your screen: stx native compile Screen.stx --format bundle --output screen.js");
|
|
672
827
|
console.log(" 3. Run: craft ios build --native-bundle screen.js");
|
|
673
828
|
} else {
|
|
674
829
|
console.log(" 2. Add your web content to dist/index.html");
|
|
@@ -699,11 +854,15 @@ async function build(options) {
|
|
|
699
854
|
cpSync(nativeBundlePath, destination);
|
|
700
855
|
}
|
|
701
856
|
if (!existsSync(join(output, "dist", "native-screen.js"))) {
|
|
702
|
-
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with stx
|
|
857
|
+
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
703
858
|
}
|
|
704
|
-
} else if (nativeBundlePath) {
|
|
705
|
-
throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
|
|
859
|
+
} else if (nativeBundlePath && !isHybrid(config)) {
|
|
860
|
+
throw new Error('--native-bundle requires renderer: "native", or nativeScreens, in craft.config.json.');
|
|
706
861
|
}
|
|
862
|
+
const problems = hybridConfigProblems(config);
|
|
863
|
+
if (problems.length)
|
|
864
|
+
throw new Error(problems.join(`
|
|
865
|
+
`));
|
|
707
866
|
if (devServer) {
|
|
708
867
|
config.devServerURL = devServer;
|
|
709
868
|
const origin = new URL(devServer).origin;
|
|
@@ -715,6 +874,15 @@ async function build(options) {
|
|
|
715
874
|
syncWebAssets(htmlPath, output);
|
|
716
875
|
console.log(` Synced: ${htmlPath} \u2192 dist/`);
|
|
717
876
|
}
|
|
877
|
+
if (isHybrid(config)) {
|
|
878
|
+
const bundle = nativeBundlePath ?? config.nativeBundle;
|
|
879
|
+
if (bundle) {
|
|
880
|
+
installNativeBundle(bundle, output);
|
|
881
|
+
console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
|
|
882
|
+
} else if (!existsSync(join(output, "dist", "native-screen.js"))) {
|
|
883
|
+
console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
|
|
884
|
+
}
|
|
885
|
+
}
|
|
718
886
|
await refreshRuntime(output, options.runtimeDir);
|
|
719
887
|
if (!generateProject)
|
|
720
888
|
return;
|
|
@@ -876,7 +1044,7 @@ Options:
|
|
|
876
1044
|
-t, --team-id <id> Apple Developer Team ID
|
|
877
1045
|
--renderer <kind> iOS renderer: web (default) or native
|
|
878
1046
|
--html-path <path> Path to HTML file or directory
|
|
879
|
-
--native-bundle <path> JavaScript bundle
|
|
1047
|
+
--native-bundle <path> JavaScript bundle from \`stx native compile\`
|
|
880
1048
|
-d, --dev-server <url> Development server URL
|
|
881
1049
|
-o, --output <dir> Output directory (default: ./ios)
|
|
882
1050
|
-s, --simulator Run on simulator instead of device
|