@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/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,8 +122,83 @@ 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'>;
|
|
147
|
+
/**
|
|
148
|
+
* Hybrid: app paths drawn as native stx screens instead of in the
|
|
149
|
+
* web view, each to the screen in the native bundle that draws it, e.g.
|
|
150
|
+
* `{ "/m": "Today", "/m/calendar": "Calendar" }`. A segment may be a
|
|
151
|
+
* parameter (`/m/workout/:id`) and the last one `*`. Needs `renderer: "web"`
|
|
152
|
+
* and `nativeBundle`; every other path stays in the web view, and a path
|
|
153
|
+
* whose screen is missing or throws falls back to it.
|
|
154
|
+
*/
|
|
155
|
+
nativeScreens?: Record<string, string>;
|
|
156
|
+
/** Hybrid: the bundle `stx native compile --format bundle` wrote, copied to `dist/native-screen.js`. */
|
|
157
|
+
nativeBundle?: string;
|
|
158
|
+
/**
|
|
159
|
+
* The native tab bar, shown from the first frame, before the page loads.
|
|
160
|
+
* The page's own `tabBar.set` replaces it later. Each `id` is the tab's
|
|
161
|
+
* root path, as the page's tab links name it.
|
|
162
|
+
*/
|
|
163
|
+
tabs?: CraftTab[];
|
|
164
|
+
/**
|
|
165
|
+
* Hybrid: web-storage keys the page writes (localStorage or
|
|
166
|
+
* sessionStorage) that native screens need, mirrored into the Keychain
|
|
167
|
+
* under the name given, e.g. `{ "auth_token": "auth.token" }` for
|
|
168
|
+
* `craft.secureStorage.getSync('auth.token')`. Turns on secure storage.
|
|
169
|
+
*/
|
|
170
|
+
shareStorage?: Record<string, string>;
|
|
171
|
+
}
|
|
172
|
+
export interface CraftTab {
|
|
173
|
+
/** The tab's root path; comes back to the page in `craftTabSelect`. */
|
|
174
|
+
id: string;
|
|
175
|
+
title: string;
|
|
176
|
+
/** An SF Symbol name. */
|
|
177
|
+
symbol?: string;
|
|
178
|
+
}
|
|
179
|
+
/** `/a//b/?q#h` → `/a/b`, the way the shell and the page compare paths. Null for a relative path. */
|
|
180
|
+
export declare function normalizeNativePath(raw: string): string | null;
|
|
181
|
+
export interface NativePathMatch {
|
|
182
|
+
path: string;
|
|
183
|
+
screen: string;
|
|
184
|
+
params: Record<string, string>;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The native screen for a path, by the rules the shell (`CraftHybridRoutes`)
|
|
188
|
+
* and the page script apply: static segments win over `:params`, which win
|
|
189
|
+
* over a trailing `*`; query items become params under the path's own.
|
|
190
|
+
*/
|
|
191
|
+
export declare function matchNativePath(nativeScreens: Record<string, string> | undefined, raw: string): NativePathMatch | null;
|
|
192
|
+
/** Whether a config mixes native screens into its web page. */
|
|
193
|
+
export declare function isHybrid(config: Pick<CraftConfig, 'renderer' | 'nativeScreens'>): boolean;
|
|
194
|
+
/** What is wrong with a config's hybrid and tab settings, one sentence each. */
|
|
195
|
+
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage'>): string[];
|
|
196
|
+
export interface CraftBackgroundRefresh {
|
|
197
|
+
enabled: boolean;
|
|
198
|
+
/** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
|
|
199
|
+
identifier?: string;
|
|
200
|
+
/** The soonest iOS may run it again, in minutes. Default 15. */
|
|
201
|
+
minimumIntervalMinutes?: number;
|
|
108
202
|
}
|
|
109
203
|
export interface CraftPrivacyDataType {
|
|
110
204
|
type: string;
|
|
@@ -145,7 +239,7 @@ export interface InitOptions {
|
|
|
145
239
|
}
|
|
146
240
|
export interface BuildOptions {
|
|
147
241
|
htmlPath?: string;
|
|
148
|
-
/** JavaScript bundle emitted by `stx
|
|
242
|
+
/** JavaScript bundle emitted by `stx native compile Screen.stx --format bundle`. */
|
|
149
243
|
nativeBundlePath?: string;
|
|
150
244
|
devServer?: string;
|
|
151
245
|
output: string;
|
|
@@ -185,6 +279,16 @@ export declare function renderOrientations(config: CraftConfig): string;
|
|
|
185
279
|
export declare function renderAppBoundDomains(config: CraftConfig): string;
|
|
186
280
|
export declare function renderUrlTypes(config: CraftConfig): string;
|
|
187
281
|
export declare function renderBackgroundModes(config: CraftConfig): string;
|
|
282
|
+
/** The background refresh task's identifier, or null when it is off. */
|
|
283
|
+
export declare function backgroundRefreshIdentifier(config: Pick<CraftConfig, 'backgroundRefresh' | 'bundleId'>): string | null;
|
|
284
|
+
/** BGTaskSchedulerPermittedIdentifiers: iOS runs no task it does not list. */
|
|
285
|
+
export declare function renderBackgroundTaskIdentifiers(config: Pick<CraftConfig, 'backgroundRefresh' | 'bundleId'>): string;
|
|
286
|
+
/**
|
|
287
|
+
* The associated-domains entitlement's entries. Each needs a service prefix,
|
|
288
|
+
* and a bare host (the easy thing to write) used to be signed as it was and
|
|
289
|
+
* then matched nothing, so universal links silently never opened the app.
|
|
290
|
+
*/
|
|
291
|
+
export declare function associatedDomainEntries(domains: string[] | undefined): string[];
|
|
188
292
|
export declare function renderEntitlements(config: CraftConfig): string;
|
|
189
293
|
export declare function renderWatchEntitlements(config: CraftConfig): string;
|
|
190
294
|
/**
|
|
@@ -219,6 +323,24 @@ export declare function pngHasAlpha(bytes: Uint8Array): boolean;
|
|
|
219
323
|
export declare const LAUNCH_LOGO = "LaunchLogo";
|
|
220
324
|
/** The launch screen: the background colour, and the splash logo on it when there is one. */
|
|
221
325
|
export declare function renderLaunchScreen(config: Pick<CraftConfig, 'splashImagePath'>): string;
|
|
326
|
+
/** Near-black or near-white, whichever reads on `background` (WCAG relative luminance). */
|
|
327
|
+
export declare function readableTextOn(background: string | undefined): string;
|
|
328
|
+
/**
|
|
329
|
+
* The page the app shows when its own page cannot be reached: the generated
|
|
330
|
+
* `dist/index.html` until the app's web assets replace it.
|
|
331
|
+
*
|
|
332
|
+
* An app whose page has a service worker normally opens from it offline, so
|
|
333
|
+
* this only shows on a first launch without one, or when the remote fails
|
|
334
|
+
* hard. It used to be a placeholder that said "Built with Craft iOS" in white
|
|
335
|
+
* on whatever the background was (invisible on a light one) with a bridge
|
|
336
|
+
* status line under it: debug output, in front of the person using the app.
|
|
337
|
+
*
|
|
338
|
+
* Now it says what happened in the app's own colours, light and dark, sized
|
|
339
|
+
* with Dynamic Type, and Retry asks the shell to load the app's page again.
|
|
340
|
+
* The shell also goes back to the app's page by itself when the connection
|
|
341
|
+
* returns, so the page does not retry on `online` as well.
|
|
342
|
+
*/
|
|
343
|
+
export declare function renderOfflinePage(config: Pick<CraftConfig, 'appName' | 'backgroundColor' | 'backgroundColorDark'>): string;
|
|
222
344
|
/** Replace the bundled web application atomically so removed assets cannot linger. */
|
|
223
345
|
export declare function syncWebAssets(source: string, output: string): void;
|
|
224
346
|
/**
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,129 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statS
|
|
|
4
4
|
import { dirname, join, resolve } from "path";
|
|
5
5
|
var {$ } = globalThis.Bun;
|
|
6
6
|
var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
|
|
7
|
+
var CRAFT_VERSION = (() => {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
|
|
10
|
+
} catch {
|
|
11
|
+
return "0.0.0";
|
|
12
|
+
}
|
|
13
|
+
})();
|
|
14
|
+
function normalizeNativePath(raw) {
|
|
15
|
+
if (typeof raw !== "string")
|
|
16
|
+
return null;
|
|
17
|
+
let path = raw;
|
|
18
|
+
const cut = path.search(/[?#]/);
|
|
19
|
+
if (cut >= 0)
|
|
20
|
+
path = path.slice(0, cut);
|
|
21
|
+
if (!path.startsWith("/"))
|
|
22
|
+
return null;
|
|
23
|
+
path = path.replace(/\/{2,}/g, "/");
|
|
24
|
+
if (path.length > 1 && path.endsWith("/"))
|
|
25
|
+
path = path.slice(0, -1);
|
|
26
|
+
return path;
|
|
27
|
+
}
|
|
28
|
+
function routeSegments(path) {
|
|
29
|
+
return path === "/" ? [] : path.slice(1).split("/");
|
|
30
|
+
}
|
|
31
|
+
function matchNativePath(nativeScreens, raw) {
|
|
32
|
+
const path = normalizeNativePath(raw);
|
|
33
|
+
if (path === null)
|
|
34
|
+
return null;
|
|
35
|
+
const routes = Object.entries(nativeScreens ?? {}).map(([pattern, screen]) => ({ pattern: normalizeNativePath(pattern), screen })).filter((route) => route.pattern !== null && !!route.screen).map((route) => ({ ...route, segments: routeSegments(route.pattern) }));
|
|
36
|
+
const wild = (segments) => segments.at(-1) === "*" ? 1 : 0;
|
|
37
|
+
const fixed = (segments) => segments.filter((s) => s !== "*" && !s.startsWith(":")).length;
|
|
38
|
+
routes.sort((a, b) => wild(a.segments) - wild(b.segments) || fixed(b.segments) - fixed(a.segments) || b.segments.length - a.segments.length || (a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0));
|
|
39
|
+
const decode = (value) => {
|
|
40
|
+
try {
|
|
41
|
+
return decodeURIComponent(value);
|
|
42
|
+
} catch {
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const parts = routeSegments(path);
|
|
47
|
+
for (const route of routes) {
|
|
48
|
+
const params = {};
|
|
49
|
+
let matched = true;
|
|
50
|
+
for (let i = 0;i < route.segments.length; i++) {
|
|
51
|
+
const segment = route.segments[i];
|
|
52
|
+
if (segment === "*") {
|
|
53
|
+
params["*"] = parts.slice(i).join("/");
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
if (i >= parts.length) {
|
|
57
|
+
matched = false;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
if (segment.startsWith(":")) {
|
|
61
|
+
if (!parts[i]) {
|
|
62
|
+
matched = false;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
params[segment.slice(1)] = decode(parts[i]);
|
|
66
|
+
} else if (segment !== parts[i]) {
|
|
67
|
+
matched = false;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!matched)
|
|
72
|
+
continue;
|
|
73
|
+
if (route.segments.at(-1) !== "*" && route.segments.length !== parts.length)
|
|
74
|
+
continue;
|
|
75
|
+
const query = raw.indexOf("?");
|
|
76
|
+
if (query >= 0) {
|
|
77
|
+
for (const item of raw.slice(query + 1).split("#")[0].split("&")) {
|
|
78
|
+
if (!item)
|
|
79
|
+
continue;
|
|
80
|
+
const [rawKey = "", ...rest] = item.split("=");
|
|
81
|
+
const key = decode(rawKey);
|
|
82
|
+
if (!key || key in params)
|
|
83
|
+
continue;
|
|
84
|
+
params[key] = decode(rest.join("=").replace(/\+/g, " "));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { path, screen: route.screen, params };
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function isHybrid(config) {
|
|
92
|
+
return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
93
|
+
}
|
|
94
|
+
function hybridConfigProblems(config) {
|
|
95
|
+
const problems = [];
|
|
96
|
+
const screens = Object.entries(config.nativeScreens ?? {});
|
|
97
|
+
if (screens.length && config.renderer === "native")
|
|
98
|
+
problems.push('nativeScreens mixes native screens into a web app; it needs renderer: "web" (renderer: "native" is all native already).');
|
|
99
|
+
for (const [pattern, screen] of screens) {
|
|
100
|
+
if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
|
|
101
|
+
problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
|
|
102
|
+
if (routeSegments(normalizeNativePath(pattern) ?? "/").slice(0, -1).includes("*"))
|
|
103
|
+
problems.push(`nativeScreens path "${pattern}" may only end in *.`);
|
|
104
|
+
if (typeof screen !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(screen))
|
|
105
|
+
problems.push(`nativeScreens screen "${String(screen)}" for "${pattern}" must be a screen name from the native bundle.`);
|
|
106
|
+
}
|
|
107
|
+
const ids = new Set;
|
|
108
|
+
for (const tab of config.tabs ?? []) {
|
|
109
|
+
if (!tab || typeof tab.id !== "string" || !tab.id || typeof tab.title !== "string" || !tab.title)
|
|
110
|
+
problems.push("Each tab needs an id (its root path) and a title.");
|
|
111
|
+
else if (ids.has(tab.id))
|
|
112
|
+
problems.push(`Tab "${tab.id}" is listed twice.`);
|
|
113
|
+
else
|
|
114
|
+
ids.add(tab.id);
|
|
115
|
+
}
|
|
116
|
+
for (const [key, target] of Object.entries(config.shareStorage ?? {})) {
|
|
117
|
+
if (!key || typeof target !== "string" || !target)
|
|
118
|
+
problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
|
|
119
|
+
}
|
|
120
|
+
return problems;
|
|
121
|
+
}
|
|
122
|
+
function installNativeBundle(source, output) {
|
|
123
|
+
if (!existsSync(source))
|
|
124
|
+
throw new Error(`Native bundle not found: ${source}`);
|
|
125
|
+
const destination = join(output, "dist", "native-screen.js");
|
|
126
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
127
|
+
if (resolve(source) !== resolve(destination))
|
|
128
|
+
cpSync(source, destination);
|
|
129
|
+
}
|
|
7
130
|
var DEFAULT_CONFIG = {
|
|
8
131
|
renderer: "web",
|
|
9
132
|
version: "1.0.0",
|
|
@@ -171,6 +294,8 @@ function plistArray(values, indent = 2) {
|
|
|
171
294
|
}
|
|
172
295
|
function renderBackgroundModes(config) {
|
|
173
296
|
const modes = new Set;
|
|
297
|
+
if (config.backgroundRefresh?.enabled)
|
|
298
|
+
modes.add("fetch");
|
|
174
299
|
if (config.enableBackgroundLocation)
|
|
175
300
|
modes.add("location");
|
|
176
301
|
if (config.enableBackgroundTasks)
|
|
@@ -184,12 +309,30 @@ function renderBackgroundModes(config) {
|
|
|
184
309
|
${plistArray([...modes])}
|
|
185
310
|
</array>`;
|
|
186
311
|
}
|
|
312
|
+
function backgroundRefreshIdentifier(config) {
|
|
313
|
+
if (!config.backgroundRefresh?.enabled)
|
|
314
|
+
return null;
|
|
315
|
+
return config.backgroundRefresh.identifier?.trim() || `${config.bundleId}.refresh`;
|
|
316
|
+
}
|
|
317
|
+
function renderBackgroundTaskIdentifiers(config) {
|
|
318
|
+
const identifier = backgroundRefreshIdentifier(config);
|
|
319
|
+
if (!identifier)
|
|
320
|
+
return "";
|
|
321
|
+
return ` <key>BGTaskSchedulerPermittedIdentifiers</key>
|
|
322
|
+
<array>
|
|
323
|
+
${plistArray([identifier])}
|
|
324
|
+
</array>`;
|
|
325
|
+
}
|
|
326
|
+
function associatedDomainEntries(domains) {
|
|
327
|
+
return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
|
|
328
|
+
}
|
|
187
329
|
function renderEntitlements(config) {
|
|
188
330
|
const entries = [];
|
|
189
|
-
|
|
331
|
+
const associatedDomains = associatedDomainEntries(config.associatedDomains);
|
|
332
|
+
if (associatedDomains.length) {
|
|
190
333
|
entries.push(` <key>com.apple.developer.associated-domains</key>
|
|
191
334
|
<array>
|
|
192
|
-
${plistArray(
|
|
335
|
+
${plistArray(associatedDomains)}
|
|
193
336
|
</array>`);
|
|
194
337
|
}
|
|
195
338
|
if (config.appGroups?.length) {
|
|
@@ -414,6 +557,102 @@ function renderLaunchScreen(config) {
|
|
|
414
557
|
return ` <key>UIColorName</key>
|
|
415
558
|
<string>LaunchBackground</string>${image}`;
|
|
416
559
|
}
|
|
560
|
+
function hexChannels(hex) {
|
|
561
|
+
const value = hex?.trim().replace(/^#/, "") ?? "";
|
|
562
|
+
const full = value.length === 3 ? [...value].map((digit) => digit + digit).join("") : value.slice(0, 6);
|
|
563
|
+
if (!/^[0-9a-f]{6}$/i.test(full))
|
|
564
|
+
return null;
|
|
565
|
+
return [0, 2, 4].map((index) => Number.parseInt(full.slice(index, index + 2), 16));
|
|
566
|
+
}
|
|
567
|
+
function readableTextOn(background) {
|
|
568
|
+
const channels = hexChannels(background);
|
|
569
|
+
if (!channels)
|
|
570
|
+
return "#0f172a";
|
|
571
|
+
const [r, g, b] = channels.map((channel) => {
|
|
572
|
+
const c = channel / 255;
|
|
573
|
+
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
574
|
+
});
|
|
575
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
576
|
+
return luminance > 0.4 ? "#0f172a" : "#f8fafc";
|
|
577
|
+
}
|
|
578
|
+
function renderOfflinePage(config) {
|
|
579
|
+
const light = hexChannels(config.backgroundColor) ? config.backgroundColor : "#ffffff";
|
|
580
|
+
const dark = hexChannels(config.backgroundColorDark) ? config.backgroundColorDark : light;
|
|
581
|
+
const name = xmlEscape(config.appName);
|
|
582
|
+
return `<!DOCTYPE html>
|
|
583
|
+
<html lang="en">
|
|
584
|
+
<head>
|
|
585
|
+
<meta charset="UTF-8">
|
|
586
|
+
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
587
|
+
<meta name="color-scheme" content="light dark">
|
|
588
|
+
<title>${name}</title>
|
|
589
|
+
<style>
|
|
590
|
+
:root { --background: ${light}; --text: ${readableTextOn(light)}; }
|
|
591
|
+
@media (prefers-color-scheme: dark) {
|
|
592
|
+
:root { --background: ${dark}; --text: ${readableTextOn(dark)}; }
|
|
593
|
+
}
|
|
594
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
595
|
+
html, body { height: 100%; }
|
|
596
|
+
body {
|
|
597
|
+
font: -apple-system-body;
|
|
598
|
+
font-family: -apple-system, system-ui, sans-serif;
|
|
599
|
+
background: var(--background);
|
|
600
|
+
color: var(--text);
|
|
601
|
+
display: flex;
|
|
602
|
+
align-items: center;
|
|
603
|
+
justify-content: center;
|
|
604
|
+
text-align: center;
|
|
605
|
+
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);
|
|
606
|
+
-webkit-user-select: none;
|
|
607
|
+
user-select: none;
|
|
608
|
+
-webkit-tap-highlight-color: transparent;
|
|
609
|
+
}
|
|
610
|
+
main { max-width: 22rem; }
|
|
611
|
+
.app { font: -apple-system-subheadline; font-weight: 600; opacity: 0.6; margin-bottom: 0.5rem; }
|
|
612
|
+
h1 { font: -apple-system-title1; font-weight: 700; margin-bottom: 0.75rem; }
|
|
613
|
+
p { opacity: 0.72; line-height: 1.4; }
|
|
614
|
+
button {
|
|
615
|
+
font: -apple-system-headline;
|
|
616
|
+
margin-top: 1.75rem;
|
|
617
|
+
min-width: 9rem;
|
|
618
|
+
min-height: 44px;
|
|
619
|
+
padding: 0.75rem 1.75rem;
|
|
620
|
+
border: 0;
|
|
621
|
+
border-radius: 999px;
|
|
622
|
+
background: var(--text);
|
|
623
|
+
color: var(--background);
|
|
624
|
+
cursor: pointer;
|
|
625
|
+
}
|
|
626
|
+
button:active { opacity: 0.7; }
|
|
627
|
+
button:disabled { opacity: 0.5; }
|
|
628
|
+
</style>
|
|
629
|
+
</head>
|
|
630
|
+
<body>
|
|
631
|
+
<main>
|
|
632
|
+
<div class="app">${name}</div>
|
|
633
|
+
<h1>You're offline</h1>
|
|
634
|
+
<p>${name} could not reach the internet. Check your connection, then try again.</p>
|
|
635
|
+
<button id="retry" type="button">Retry</button>
|
|
636
|
+
</main>
|
|
637
|
+
<script>
|
|
638
|
+
(function () {
|
|
639
|
+
var button = document.getElementById('retry');
|
|
640
|
+
function retry() {
|
|
641
|
+
button.disabled = true;
|
|
642
|
+
button.textContent = 'Retrying';
|
|
643
|
+
var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;
|
|
644
|
+
if (handler) handler.postMessage({ action: 'retryRemote' });
|
|
645
|
+
else location.reload();
|
|
646
|
+
// Still here a while later means the page is still out of reach.
|
|
647
|
+
setTimeout(function () { button.disabled = false; button.textContent = 'Retry'; }, 12000);
|
|
648
|
+
}
|
|
649
|
+
button.addEventListener('click', retry);
|
|
650
|
+
})();
|
|
651
|
+
</script>
|
|
652
|
+
</body>
|
|
653
|
+
</html>
|
|
654
|
+
`;
|
|
655
|
+
}
|
|
417
656
|
function syncWebAssets(source, output) {
|
|
418
657
|
const sourcePath = resolve(source);
|
|
419
658
|
if (!existsSync(sourcePath))
|
|
@@ -520,16 +759,24 @@ async function init(options) {
|
|
|
520
759
|
}
|
|
521
760
|
if (config.enableBackgroundLocation)
|
|
522
761
|
config.enableGeolocation = true;
|
|
762
|
+
const problems = hybridConfigProblems(config);
|
|
763
|
+
if (problems.length)
|
|
764
|
+
throw new Error(problems.join(`
|
|
765
|
+
`));
|
|
766
|
+
if (Object.keys(config.shareStorage ?? {}).length)
|
|
767
|
+
config.enableSecureStorage = true;
|
|
523
768
|
writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
524
769
|
const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
|
|
525
|
-
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
770
|
+
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
|
|
526
771
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
527
772
|
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
528
773
|
cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
|
|
529
774
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
530
775
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
776
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
|
|
777
|
+
cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
|
|
531
778
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
532
|
-
const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
|
|
779
|
+
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>
|
|
533
780
|
<true/>
|
|
534
781
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
535
782
|
<true/>` : "");
|
|
@@ -619,51 +866,19 @@ ${nativeDependencies.join(`
|
|
|
619
866
|
writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
|
|
620
867
|
writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
621
868
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
630
|
-
body {
|
|
631
|
-
font-family: -apple-system, system-ui, sans-serif;
|
|
632
|
-
background: ${config.backgroundColor};
|
|
633
|
-
color: white;
|
|
634
|
-
min-height: 100vh;
|
|
635
|
-
display: flex;
|
|
636
|
-
justify-content: center;
|
|
637
|
-
align-items: center;
|
|
638
|
-
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
|
639
|
-
}
|
|
640
|
-
.container { text-align: center; padding: 2rem; }
|
|
641
|
-
h1 { font-size: 2.5rem; margin-bottom: 1rem; }
|
|
642
|
-
p { opacity: 0.7; }
|
|
643
|
-
.ready { color: #4ade80; font-size: 0.9rem; margin-top: 2rem; }
|
|
644
|
-
</style>
|
|
645
|
-
</head>
|
|
646
|
-
<body>
|
|
647
|
-
<div class="container">
|
|
648
|
-
<h1>\u26A1 ${xmlEscape(displayName)}</h1>
|
|
649
|
-
<p>Built with Craft iOS</p>
|
|
650
|
-
<p class="ready" id="status">Waiting for Craft bridge...</p>
|
|
651
|
-
</div>
|
|
652
|
-
<script>
|
|
653
|
-
window.addEventListener('craftReady', (e) => {
|
|
654
|
-
document.getElementById('status').textContent = \`\u2713 Craft bridge ready (platform: \${e.detail.platform})\`;
|
|
655
|
-
console.log('Craft capabilities:', e.detail.capabilities);
|
|
656
|
-
});
|
|
657
|
-
</script>
|
|
658
|
-
</body>
|
|
659
|
-
</html>`;
|
|
660
|
-
writeFileSync(join(output, "dist", "index.html"), placeholderHtml);
|
|
869
|
+
writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
|
|
870
|
+
if (isHybrid(config)) {
|
|
871
|
+
if (config.nativeBundle)
|
|
872
|
+
installNativeBundle(config.nativeBundle, output);
|
|
873
|
+
else
|
|
874
|
+
console.warn(" \u26A0 nativeScreens is set without nativeBundle: every path opens in the web view until `craft ios build --native-bundle` supplies one.");
|
|
875
|
+
}
|
|
661
876
|
console.log("\u2705 Project initialized");
|
|
662
877
|
console.log("");
|
|
663
878
|
console.log("Next steps:");
|
|
664
879
|
console.log(` 1. cd ${output}`);
|
|
665
880
|
if (config.renderer === "native") {
|
|
666
|
-
console.log(" 2. Compile your screen: stx
|
|
881
|
+
console.log(" 2. Compile your screen: stx native compile Screen.stx --format bundle --output screen.js");
|
|
667
882
|
console.log(" 3. Run: craft ios build --native-bundle screen.js");
|
|
668
883
|
} else {
|
|
669
884
|
console.log(" 2. Add your web content to dist/index.html");
|
|
@@ -694,11 +909,15 @@ async function build(options) {
|
|
|
694
909
|
cpSync(nativeBundlePath, destination);
|
|
695
910
|
}
|
|
696
911
|
if (!existsSync(join(output, "dist", "native-screen.js"))) {
|
|
697
|
-
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with stx
|
|
912
|
+
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
698
913
|
}
|
|
699
|
-
} else if (nativeBundlePath) {
|
|
700
|
-
throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
|
|
914
|
+
} else if (nativeBundlePath && !isHybrid(config)) {
|
|
915
|
+
throw new Error('--native-bundle requires renderer: "native", or nativeScreens, in craft.config.json.');
|
|
701
916
|
}
|
|
917
|
+
const problems = hybridConfigProblems(config);
|
|
918
|
+
if (problems.length)
|
|
919
|
+
throw new Error(problems.join(`
|
|
920
|
+
`));
|
|
702
921
|
if (devServer) {
|
|
703
922
|
config.devServerURL = devServer;
|
|
704
923
|
const origin = new URL(devServer).origin;
|
|
@@ -710,6 +929,15 @@ async function build(options) {
|
|
|
710
929
|
syncWebAssets(htmlPath, output);
|
|
711
930
|
console.log(` Synced: ${htmlPath} \u2192 dist/`);
|
|
712
931
|
}
|
|
932
|
+
if (isHybrid(config)) {
|
|
933
|
+
const bundle = nativeBundlePath ?? config.nativeBundle;
|
|
934
|
+
if (bundle) {
|
|
935
|
+
installNativeBundle(bundle, output);
|
|
936
|
+
console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
|
|
937
|
+
} else if (!existsSync(join(output, "dist", "native-screen.js"))) {
|
|
938
|
+
console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
|
|
939
|
+
}
|
|
940
|
+
}
|
|
713
941
|
await refreshRuntime(output, options.runtimeDir);
|
|
714
942
|
if (!generateProject)
|
|
715
943
|
return;
|
|
@@ -844,20 +1072,29 @@ export {
|
|
|
844
1072
|
renderRuntimeSettings,
|
|
845
1073
|
renderPrivacyManifest,
|
|
846
1074
|
renderOrientations,
|
|
1075
|
+
renderOfflinePage,
|
|
847
1076
|
renderLaunchScreen,
|
|
848
1077
|
renderEntitlements,
|
|
849
1078
|
renderDeviceFamilies,
|
|
1079
|
+
renderBackgroundTaskIdentifiers,
|
|
850
1080
|
renderBackgroundModes,
|
|
851
1081
|
renderAppearance,
|
|
852
1082
|
renderAppBoundDomains,
|
|
1083
|
+
readableTextOn,
|
|
853
1084
|
productName,
|
|
854
1085
|
pngHasAlpha,
|
|
855
1086
|
pickSimulator,
|
|
856
1087
|
orderSimulators,
|
|
857
1088
|
open,
|
|
1089
|
+
normalizeNativePath,
|
|
1090
|
+
matchNativePath,
|
|
1091
|
+
isHybrid,
|
|
858
1092
|
installRuntime,
|
|
859
1093
|
init,
|
|
1094
|
+
hybridConfigProblems,
|
|
860
1095
|
build,
|
|
861
1096
|
bootSimulator,
|
|
1097
|
+
backgroundRefreshIdentifier,
|
|
1098
|
+
associatedDomainEntries,
|
|
862
1099
|
LAUNCH_LOGO
|
|
863
1100
|
};
|