craft-native 0.0.228 → 0.0.231
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +7 -0
- package/dist/android/src/index.js +32 -0
- package/dist/android/templates/MainActivityNative.kt.template +8 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +8 -1
- package/dist/ios/src/index.js +58 -4
- package/dist/ios/src/testing.d.ts +13 -0
- package/dist/ios/templates/Info.plist.template +1 -0
- package/dist/ios/templates/project.yml.template +1 -0
- package/dist/ios/templates/testing/CraftTimedInput.h +11 -0
- package/dist/ios/templates/testing/CraftTimedInput.m +75 -0
- package/dist/ios/templates/testing/DriverTests.swift.template +48 -0
- package/dist/ios/templates/testing/project.yml.template +1 -0
- package/package.json +1 -1
|
@@ -4,9 +4,16 @@
|
|
|
4
4
|
* Generates native Android apps from web content using WebView.
|
|
5
5
|
*/
|
|
6
6
|
export { runKotlinFixture } from './testing.js';
|
|
7
|
+
/** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
|
|
8
|
+
export declare function syncNativeFonts(fonts: CraftAndroidConfig['nativeFonts'], output: string): string[];
|
|
7
9
|
/** Oldest Android API level supported by the generated Kotlin runtime. */
|
|
8
10
|
export declare const ANDROID_MIN_SDK = 26;
|
|
9
11
|
export interface CraftAndroidConfig {
|
|
12
|
+
/** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
|
|
13
|
+
nativeFonts?: Record<string, {
|
|
14
|
+
src: string;
|
|
15
|
+
license?: string;
|
|
16
|
+
}>;
|
|
10
17
|
appName: string;
|
|
11
18
|
packageName: string;
|
|
12
19
|
renderer?: 'web' | 'native';
|
|
@@ -134,6 +134,35 @@ tasks.register('runFixture', JavaExec) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
// ../android/src/index.ts
|
|
137
|
+
function syncNativeFonts(fonts, output) {
|
|
138
|
+
const destination = join2(output, "app/src/main/assets/NativeFonts");
|
|
139
|
+
const names = new Set;
|
|
140
|
+
const files = Object.entries(fonts ?? {}).map(([family, entry]) => {
|
|
141
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(family))
|
|
142
|
+
throw new Error(`Invalid native font PostScript name: ${family}`);
|
|
143
|
+
if (names.has(family.toLowerCase()))
|
|
144
|
+
throw new Error(`Duplicate native font name: ${family}`);
|
|
145
|
+
names.add(family.toLowerCase());
|
|
146
|
+
if ([entry.src, entry.license].some((file) => file && resolve(file).startsWith(resolve(destination) + sep)))
|
|
147
|
+
throw new Error("Native font sources must be outside the generated NativeFonts directory");
|
|
148
|
+
const extension = extname(entry.src).toLowerCase();
|
|
149
|
+
if (![".ttf", ".otf"].includes(extension) || !existsSync(entry.src) || !statSync(entry.src).isFile())
|
|
150
|
+
throw new Error(`Native font ${family} must reference an existing TTF or OTF file: ${entry.src}`);
|
|
151
|
+
if (entry.license && (!existsSync(entry.license) || !statSync(entry.license).isFile()))
|
|
152
|
+
throw new Error(`Native font license not found: ${entry.license}`);
|
|
153
|
+
return { family, ...entry, filename: `${family}${extension}` };
|
|
154
|
+
});
|
|
155
|
+
rmSync(destination, { recursive: true, force: true });
|
|
156
|
+
if (!files.length)
|
|
157
|
+
return [];
|
|
158
|
+
mkdirSync(destination, { recursive: true });
|
|
159
|
+
for (const file of files) {
|
|
160
|
+
cpSync(file.src, join2(destination, file.filename));
|
|
161
|
+
if (file.license)
|
|
162
|
+
cpSync(file.license, join2(destination, `${file.family}.LICENSE.txt`));
|
|
163
|
+
}
|
|
164
|
+
return files.map((file) => `NativeFonts/${file.filename}`);
|
|
165
|
+
}
|
|
137
166
|
var TEMPLATES_DIR = join2(dirname2(import.meta.dir), "templates");
|
|
138
167
|
function resolveCraftAndroidVersion(startDir = import.meta.dir) {
|
|
139
168
|
let directory = resolve(startDir);
|
|
@@ -884,6 +913,7 @@ zipStorePath=wrapper/dists
|
|
|
884
913
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
|
885
914
|
`;
|
|
886
915
|
writeFileSync(join2(output, "app/src/main/res/layout/activity_main.xml"), activityMainXml);
|
|
916
|
+
syncNativeFonts(config.nativeFonts, output);
|
|
887
917
|
const placeholderHtml = `<!DOCTYPE html>
|
|
888
918
|
<html>
|
|
889
919
|
<head>
|
|
@@ -995,6 +1025,7 @@ async function build(options) {
|
|
|
995
1025
|
writeAndroidConfig(output, config);
|
|
996
1026
|
console.log(` Synced: ${htmlPath} \u2192 assets/`);
|
|
997
1027
|
}
|
|
1028
|
+
syncNativeFonts(config.nativeFonts, output);
|
|
998
1029
|
if (!compile)
|
|
999
1030
|
return;
|
|
1000
1031
|
const gradlewPath = join2(output, "gradlew");
|
|
@@ -1066,6 +1097,7 @@ async function run(options) {
|
|
|
1066
1097
|
}
|
|
1067
1098
|
}
|
|
1068
1099
|
export {
|
|
1100
|
+
syncNativeFonts,
|
|
1069
1101
|
syncAndroidWebAssets,
|
|
1070
1102
|
runKotlinFixture,
|
|
1071
1103
|
run,
|
|
@@ -3810,6 +3810,11 @@ class MainActivity : AppCompatActivity() {
|
|
|
3810
3810
|
private val imageCache by lazy { CraftNativeImageCache(File(cacheDir, "craft-native-images")) }
|
|
3811
3811
|
/** Lays out each isolate's prelude (the synchronous stores) off the main thread. */
|
|
3812
3812
|
private val preludeExecutor = Executors.newSingleThreadExecutor()
|
|
3813
|
+
private val bundledFonts: Map<String, Typeface> by lazy {
|
|
3814
|
+
(assets.list("NativeFonts") ?: emptyArray()).filter { it.endsWith(".ttf") || it.endsWith(".otf") }
|
|
3815
|
+
.associate { filename -> filename.substringBeforeLast('.') to Typeface.createFromAsset(assets, "NativeFonts/$filename") }
|
|
3816
|
+
}
|
|
3817
|
+
|
|
3813
3818
|
private val imageSources = mutableMapOf<ImageView, String>()
|
|
3814
3819
|
private val imageJobs = mutableMapOf<ImageView, Future<*>>()
|
|
3815
3820
|
private val imageErrors = mutableMapOf<ImageView, String>()
|
|
@@ -6351,9 +6356,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
6351
6356
|
}
|
|
6352
6357
|
val italic = craftNativeOptionalString(style.opt("fontStyle")) == "italic"
|
|
6353
6358
|
val family = craftNativeOptionalString(style.opt("fontFamily"))?.takeIf { it.isNotBlank() }
|
|
6359
|
+
val base = family?.let { bundledFonts[it] } ?: Typeface.create(family, Typeface.NORMAL)
|
|
6354
6360
|
view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
6355
|
-
Typeface.create(
|
|
6356
|
-
} else Typeface.create(
|
|
6361
|
+
Typeface.create(base, weight, italic)
|
|
6362
|
+
} else Typeface.create(base, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
|
|
6357
6363
|
(if (italic) Typeface.ITALIC else Typeface.NORMAL))
|
|
6358
6364
|
val textAlign = craftNativeOptionalString(style.opt("textAlign"))
|
|
6359
6365
|
view.textAlignment = when (textAlign) {
|
package/dist/cli.js
CHANGED
|
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3424
3424
|
`);
|
|
3425
3425
|
}
|
|
3426
3426
|
// package.json
|
|
3427
|
-
var version = "0.0.
|
|
3427
|
+
var version = "0.0.231";
|
|
3428
3428
|
|
|
3429
3429
|
// bin/cli.ts
|
|
3430
3430
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -3,10 +3,17 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Generates native iOS apps from web content using WKWebView.
|
|
5
5
|
*/
|
|
6
|
+
/** Package custom fonts and their licenses; removed fonts cannot linger in the app. */
|
|
7
|
+
export declare function syncNativeFonts(fonts: CraftConfig['nativeFonts'], output: string): string[];
|
|
6
8
|
/** This package's version, which the app names in its user agent (`Craft/<version>`). */
|
|
7
9
|
export declare function resolveCraftVersion(startDir?: string): string;
|
|
8
10
|
export type CraftAppearance = 'light' | 'dark' | 'system';
|
|
9
11
|
export interface CraftConfig {
|
|
12
|
+
/** Bundled font files keyed by their PostScript font name, used as native fontFamily. */
|
|
13
|
+
nativeFonts?: Record<string, {
|
|
14
|
+
src: string;
|
|
15
|
+
license?: string;
|
|
16
|
+
}>;
|
|
10
17
|
appName: string;
|
|
11
18
|
bundleId: string;
|
|
12
19
|
/** Opt in to UIKit + JavaScriptCore instead of the default WebView. */
|
|
@@ -563,4 +570,4 @@ export declare function bootSimulator(device: SimulatorDevice): Promise<void>;
|
|
|
563
570
|
export declare function showSimulator(): Promise<void>;
|
|
564
571
|
export declare function run(options: RunOptions): Promise<void>;
|
|
565
572
|
export { createNativeMutationValidator, initSimulatorDriver, serializeSimulatorSteps } from './testing.js';
|
|
566
|
-
export type { NativeMutationValidator, NativeValidationResult, SimulatorDriverOptions, SimulatorStep } from './testing.js';
|
|
573
|
+
export type { NativeMutationValidator, NativeValidationResult, SimulatorDriverOptions, SimulatorStep, TimedTouchPoint } from './testing.js';
|
package/dist/ios/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// ../ios/src/index.ts
|
|
3
3
|
import { cpSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "fs";
|
|
4
|
-
import { dirname as dirname2, join as join3, resolve } from "path";
|
|
4
|
+
import { dirname as dirname2, extname, join as join3, resolve, sep as sep2 } from "path";
|
|
5
5
|
var {$ } = globalThis.Bun;
|
|
6
6
|
|
|
7
7
|
// ../ios/src/source-audit.ts
|
|
@@ -90,6 +90,8 @@ function initSimulatorDriver(options) {
|
|
|
90
90
|
for (const [source, destination] of [
|
|
91
91
|
["HostApp.swift", "Host/HostApp.swift"],
|
|
92
92
|
["DriverTests.swift.template", "UITests/DriverTests.swift"],
|
|
93
|
+
["CraftTimedInput.h", "UITests/CraftTimedInput.h"],
|
|
94
|
+
["CraftTimedInput.m", "UITests/CraftTimedInput.m"],
|
|
93
95
|
["project.yml.template", "project.yml"]
|
|
94
96
|
]) {
|
|
95
97
|
const path = join2(output, destination);
|
|
@@ -127,7 +129,23 @@ function serializeSimulatorSteps(steps) {
|
|
|
127
129
|
};
|
|
128
130
|
const line = (step) => {
|
|
129
131
|
let arg = "";
|
|
130
|
-
if ("
|
|
132
|
+
if ("paths" in step) {
|
|
133
|
+
if (!step.paths.length || step.paths.length > 64)
|
|
134
|
+
throw new Error("Expected 1\u201364 touch paths");
|
|
135
|
+
for (const path of step.paths) {
|
|
136
|
+
if (path.length < 2 || path.length > 64)
|
|
137
|
+
throw new Error("Expected 2\u201364 points per touch");
|
|
138
|
+
let previous = -1;
|
|
139
|
+
for (const point of path) {
|
|
140
|
+
if ("tab" in point ? !point.tab.trim() || /[\r\n]/.test(point.tab) : number(point.x) > 1 || number(point.y) > 1)
|
|
141
|
+
throw new Error("Touch targets must be tab names or normalized coordinates");
|
|
142
|
+
if (number(point.at) > 12 || point.at <= previous)
|
|
143
|
+
throw new Error("Touch coordinates must be normalized and times strictly increase within 12 seconds");
|
|
144
|
+
previous = point.at;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
arg = JSON.stringify(step.paths);
|
|
148
|
+
} else if ("step" in step)
|
|
131
149
|
arg = `${token(step.id)} ${line(step.step)}`;
|
|
132
150
|
else if ("seconds" in step && !("text" in step))
|
|
133
151
|
arg = String(number(step.seconds));
|
|
@@ -215,6 +233,35 @@ function createNativeMutationValidator() {
|
|
|
215
233
|
}
|
|
216
234
|
|
|
217
235
|
// ../ios/src/index.ts
|
|
236
|
+
function syncNativeFonts(fonts, output) {
|
|
237
|
+
const destination = join3(output, "NativeFonts");
|
|
238
|
+
const names = new Set;
|
|
239
|
+
const files = Object.entries(fonts ?? {}).map(([family, entry]) => {
|
|
240
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(family))
|
|
241
|
+
throw new Error(`Invalid native font PostScript name: ${family}`);
|
|
242
|
+
if (names.has(family.toLowerCase()))
|
|
243
|
+
throw new Error(`Duplicate native font name: ${family}`);
|
|
244
|
+
names.add(family.toLowerCase());
|
|
245
|
+
if ([entry.src, entry.license].some((file) => file && resolve(file).startsWith(resolve(destination) + sep2)))
|
|
246
|
+
throw new Error("Native font sources must be outside the generated NativeFonts directory");
|
|
247
|
+
const extension = extname(entry.src).toLowerCase();
|
|
248
|
+
if (![".ttf", ".otf"].includes(extension) || !existsSync(entry.src) || !statSync(entry.src).isFile())
|
|
249
|
+
throw new Error(`Native font ${family} must reference an existing TTF or OTF file: ${entry.src}`);
|
|
250
|
+
if (entry.license && (!existsSync(entry.license) || !statSync(entry.license).isFile()))
|
|
251
|
+
throw new Error(`Native font license not found: ${entry.license}`);
|
|
252
|
+
return { family, ...entry, filename: `${family}${extension}` };
|
|
253
|
+
});
|
|
254
|
+
rmSync2(destination, { recursive: true, force: true });
|
|
255
|
+
if (!files.length)
|
|
256
|
+
return [];
|
|
257
|
+
mkdirSync2(destination, { recursive: true });
|
|
258
|
+
for (const file of files) {
|
|
259
|
+
cpSync(file.src, join3(destination, file.filename));
|
|
260
|
+
if (file.license)
|
|
261
|
+
cpSync(file.license, join3(destination, `${file.family}.LICENSE.txt`));
|
|
262
|
+
}
|
|
263
|
+
return files.map((file) => `NativeFonts/${file.filename}`);
|
|
264
|
+
}
|
|
218
265
|
var TEMPLATES_DIR = join3(dirname2(import.meta.dir), "templates");
|
|
219
266
|
function resolveCraftVersion(startDir = import.meta.dir) {
|
|
220
267
|
let directory = resolve(startDir);
|
|
@@ -1088,8 +1135,12 @@ async function init(options) {
|
|
|
1088
1135
|
config.enableSecureStorage = true;
|
|
1089
1136
|
writeFileSync2(join3(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
1090
1137
|
writeShell(output, config);
|
|
1138
|
+
const nativeFontFiles = syncNativeFonts(config.nativeFonts, output);
|
|
1091
1139
|
const infoPlistTemplate = readFileSync2(join3(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
1092
|
-
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(/\{\{
|
|
1140
|
+
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(/\{\{NATIVE_FONTS\}\}/g, nativeFontFiles.length ? ` <key>UIAppFonts</key>
|
|
1141
|
+
<array>
|
|
1142
|
+
${plistArray(nativeFontFiles, 2)}
|
|
1143
|
+
</array>` : "").replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
|
|
1093
1144
|
<true/>
|
|
1094
1145
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
1095
1146
|
<true/>` : "");
|
|
@@ -1156,7 +1207,9 @@ async function init(options) {
|
|
|
1156
1207
|
} else {
|
|
1157
1208
|
rmSync2(join3(output, "Runtime"), { recursive: true, force: true });
|
|
1158
1209
|
}
|
|
1159
|
-
const projectYml = projectYmlTemplate.replace(/\{\{
|
|
1210
|
+
const projectYml = projectYmlTemplate.replace(/\{\{NATIVE_FONT_RESOURCES\}\}/g, nativeFontFiles.length ? ` - path: NativeFonts
|
|
1211
|
+
type: folder
|
|
1212
|
+
buildPhase: resources` : "").replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{FEATURE_SETTINGS\}\}/g, renderFeatureSettings(config)).replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
|
|
1160
1213
|
${nativeDependencies.join(`
|
|
1161
1214
|
`)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
|
|
1162
1215
|
`));
|
|
@@ -1387,6 +1440,7 @@ async function run(options) {
|
|
|
1387
1440
|
export {
|
|
1388
1441
|
usesNativeBundle,
|
|
1389
1442
|
syncWebAssets,
|
|
1443
|
+
syncNativeFonts,
|
|
1390
1444
|
showSimulator,
|
|
1391
1445
|
serializeSimulatorSteps,
|
|
1392
1446
|
run,
|
|
@@ -12,6 +12,14 @@ export interface SimulatorDriverOptions {
|
|
|
12
12
|
}
|
|
13
13
|
/** Generate the reusable XCUITest shell from package templates. No app-owned Swift. */
|
|
14
14
|
export declare function initSimulatorDriver(options: SimulatorDriverOptions): void;
|
|
15
|
+
export type TimedTouchPoint = {
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
at: number;
|
|
19
|
+
} | {
|
|
20
|
+
tab: string;
|
|
21
|
+
at: number;
|
|
22
|
+
};
|
|
15
23
|
export type SimulatorStep = {
|
|
16
24
|
command: 'launch' | 'activate' | 'terminate' | 'home' | 'back' | 'return' | 'unlabelled' | 'notifications';
|
|
17
25
|
} | {
|
|
@@ -46,6 +54,11 @@ export type SimulatorStep = {
|
|
|
46
54
|
command: 'tapxy';
|
|
47
55
|
x: number;
|
|
48
56
|
y: number;
|
|
57
|
+
}
|
|
58
|
+
/** One native event record, without quiescence between paths. Times are seconds. */
|
|
59
|
+
| {
|
|
60
|
+
command: 'touches';
|
|
61
|
+
paths: TimedTouchPoint[][];
|
|
49
62
|
} | {
|
|
50
63
|
command: 'field';
|
|
51
64
|
index: number;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import <CoreGraphics/CoreGraphics.h>
|
|
3
|
+
|
|
4
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
5
|
+
/// Test-runner-only native events. Never linked into a shipped application.
|
|
6
|
+
@interface CraftTimedInput : NSObject
|
|
7
|
+
+ (BOOL)synthesizePaths:(NSArray<NSArray<NSDictionary<NSString *, NSNumber *> *> *> *)paths
|
|
8
|
+
frame:(CGRect)frame
|
|
9
|
+
error:(NSError * _Nullable * _Nullable)error NS_SWIFT_NAME(synthesize(paths:frame:));
|
|
10
|
+
@end
|
|
11
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#import "CraftTimedInput.h"
|
|
2
|
+
#import <UIKit/UIKit.h>
|
|
3
|
+
#import <math.h>
|
|
4
|
+
|
|
5
|
+
// XCTest's own touch-record API is private. Look up classes and check every
|
|
6
|
+
// selector at runtime; unsupported Xcode versions fail rather than skipping or
|
|
7
|
+
// falling back to serialized taps. These declarations create no class linkage.
|
|
8
|
+
@interface NSObject (CraftTestingTouchRecords)
|
|
9
|
+
- (id)initForTouchAtPoint:(CGPoint)point offset:(double)offset;
|
|
10
|
+
- (void)moveToPoint:(CGPoint)point atOffset:(double)offset;
|
|
11
|
+
- (void)liftUpAtOffset:(double)offset;
|
|
12
|
+
- (id)initWithName:(NSString *)name interfaceOrientation:(NSInteger)orientation;
|
|
13
|
+
- (void)addPointerEventPath:(id)path;
|
|
14
|
+
- (BOOL)synthesizeWithError:(NSError **)error;
|
|
15
|
+
@end
|
|
16
|
+
|
|
17
|
+
@implementation CraftTimedInput
|
|
18
|
+
+ (BOOL)fail:(NSString *)message error:(NSError **)error {
|
|
19
|
+
if (error) *error = [NSError errorWithDomain:@"CraftTimedInput" code:1
|
|
20
|
+
userInfo:@{NSLocalizedDescriptionKey: message}];
|
|
21
|
+
return NO;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
+ (BOOL)synthesizePaths:(NSArray *)paths frame:(CGRect)frame error:(NSError **)error {
|
|
25
|
+
if (![paths isKindOfClass:NSArray.class] || paths.count == 0 || paths.count > 64
|
|
26
|
+
|| CGRectIsEmpty(frame) || !isfinite(frame.size.width) || !isfinite(frame.size.height))
|
|
27
|
+
return [self fail:@"Expected 1–64 timed touch paths and a visible app frame" error:error];
|
|
28
|
+
// Validate the entire record before any native event is submitted.
|
|
29
|
+
for (id path in paths) {
|
|
30
|
+
if (![path isKindOfClass:NSArray.class] || [path count] < 2 || [path count] > 64)
|
|
31
|
+
return [self fail:@"Each touch needs 2–64 points" error:error];
|
|
32
|
+
double previous = -1;
|
|
33
|
+
for (id point in path) {
|
|
34
|
+
if (![point isKindOfClass:NSDictionary.class]) return [self fail:@"Invalid touch point" error:error];
|
|
35
|
+
for (NSString *key in @[@"x", @"y", @"at"]) {
|
|
36
|
+
id value = point[key];
|
|
37
|
+
if (![value isKindOfClass:NSNumber.class] || !isfinite([value doubleValue]))
|
|
38
|
+
return [self fail:@"Touch coordinates and times must be finite numbers" error:error];
|
|
39
|
+
}
|
|
40
|
+
double x = [point[@"x"] doubleValue], y = [point[@"y"] doubleValue], at = [point[@"at"] doubleValue];
|
|
41
|
+
if (x < 0 || x > 1 || y < 0 || y > 1 || at < 0 || at > 12 || at <= previous)
|
|
42
|
+
return [self fail:@"Touch coordinates must be normalized; times must increase within 12 seconds" error:error];
|
|
43
|
+
previous = at;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
Class recordClass = NSClassFromString(@"XCSynthesizedEventRecord");
|
|
47
|
+
Class pathClass = NSClassFromString(@"XCPointerEventPath");
|
|
48
|
+
for (NSString *name in @[@"initWithName:interfaceOrientation:", @"addPointerEventPath:", @"synthesizeWithError:"])
|
|
49
|
+
if (![recordClass instancesRespondToSelector:NSSelectorFromString(name)])
|
|
50
|
+
return [self fail:@"XCTest does not support timed native event records" error:error];
|
|
51
|
+
for (NSString *name in @[@"initForTouchAtPoint:offset:", @"moveToPoint:atOffset:", @"liftUpAtOffset:"])
|
|
52
|
+
if (![pathClass instancesRespondToSelector:NSSelectorFromString(name)])
|
|
53
|
+
return [self fail:@"XCTest does not support timed native touch paths" error:error];
|
|
54
|
+
id record = [[recordClass alloc] initWithName:@"Craft timed native input" interfaceOrientation:UIInterfaceOrientationPortrait];
|
|
55
|
+
if (!record) return [self fail:@"XCTest refused a native event record" error:error];
|
|
56
|
+
for (NSArray *points in paths) {
|
|
57
|
+
NSDictionary *first = points.firstObject;
|
|
58
|
+
CGPoint start = CGPointMake(frame.origin.x + [first[@"x"] doubleValue] * frame.size.width,
|
|
59
|
+
frame.origin.y + [first[@"y"] doubleValue] * frame.size.height);
|
|
60
|
+
id path = [[pathClass alloc] initForTouchAtPoint:start offset:[first[@"at"] doubleValue]];
|
|
61
|
+
if (!path) return [self fail:@"XCTest refused a native touch path" error:error];
|
|
62
|
+
for (NSUInteger index = 1; index < points.count; index++) {
|
|
63
|
+
NSDictionary *point = points[index];
|
|
64
|
+
CGPoint location = CGPointMake(frame.origin.x + [point[@"x"] doubleValue] * frame.size.width,
|
|
65
|
+
frame.origin.y + [point[@"y"] doubleValue] * frame.size.height);
|
|
66
|
+
[path moveToPoint:location atOffset:[point[@"at"] doubleValue]];
|
|
67
|
+
}
|
|
68
|
+
[path liftUpAtOffset:[points.lastObject[@"at"] doubleValue]];
|
|
69
|
+
[record addPointerEventPath:path];
|
|
70
|
+
}
|
|
71
|
+
// One dispatch retains the native offsets between different fingers/taps.
|
|
72
|
+
// Individual XCUICoordinate.tap() calls would wait for app quiescence.
|
|
73
|
+
return [record synthesizeWithError:error];
|
|
74
|
+
}
|
|
75
|
+
@end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import XCTest
|
|
2
|
+
import Darwin
|
|
2
3
|
|
|
3
4
|
/// Drives the installed app from a script the Mac serves (netswitch.ts
|
|
4
5
|
/// `/script`), one command a line:
|
|
@@ -20,6 +21,7 @@ import XCTest
|
|
|
20
21
|
/// tap <text> first hittable element whose label contains text
|
|
21
22
|
/// tapif <text> tap it when it is on screen, else carry on (the big timer's "Show the controls")
|
|
22
23
|
/// tapid <identifier>
|
|
24
|
+
/// touches <JSON> timed normalized touch paths in one native dispatch
|
|
23
25
|
/// tapxy <x> <y> normalised 0…1 of the screen
|
|
24
26
|
/// tapexact <label> a button whose label is exactly this
|
|
25
27
|
/// back the navigation bar's back button, else an edge swipe
|
|
@@ -288,6 +290,52 @@ final class DriverTests: XCTestCase {
|
|
|
288
290
|
let target = identified(arg)
|
|
289
291
|
if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
|
|
290
292
|
else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
|
|
293
|
+
case "touches":
|
|
294
|
+
do {
|
|
295
|
+
guard let data = arg.data(using: .utf8),
|
|
296
|
+
let rawPaths = try JSONSerialization.jsonObject(with: data) as? [[[String: Any]]] else {
|
|
297
|
+
check(false, "touches requires JSON arrays of x/y/at or tab/at points")
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
let frame = app.frame
|
|
301
|
+
var tabLocations: [String: CGPoint] = [:]
|
|
302
|
+
var paths: [[[String: NSNumber]]] = []
|
|
303
|
+
for rawPath in rawPaths {
|
|
304
|
+
var path: [[String: NSNumber]] = []
|
|
305
|
+
for rawPoint in rawPath {
|
|
306
|
+
var point = rawPoint
|
|
307
|
+
if let name = point.removeValue(forKey: "tab") as? String {
|
|
308
|
+
if tabLocations[name] == nil {
|
|
309
|
+
guard let button = tabButton(name), frame.width > 0, frame.height > 0 else {
|
|
310
|
+
check(false, "Timed native input cannot find tab \(name)")
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
let rect = button.frame
|
|
314
|
+
tabLocations[name] = CGPoint(x: (rect.midX - frame.minX) / frame.width,
|
|
315
|
+
y: (rect.midY - frame.minY) / frame.height)
|
|
316
|
+
}
|
|
317
|
+
point["x"] = tabLocations[name]!.x
|
|
318
|
+
point["y"] = tabLocations[name]!.y
|
|
319
|
+
}
|
|
320
|
+
guard let numbers = point as? [String: NSNumber] else {
|
|
321
|
+
check(false, "Timed touch points must contain numeric coordinates and times")
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
path.append(numbers)
|
|
325
|
+
}
|
|
326
|
+
paths.append(path)
|
|
327
|
+
}
|
|
328
|
+
let scheduled = try JSONSerialization.data(withJSONObject: paths, options: [.sortedKeys])
|
|
329
|
+
let started = ProcessInfo.processInfo.systemUptime
|
|
330
|
+
if (paths.flatMap { $0 }.compactMap { $0["at"]?.doubleValue }.max() ?? 0) >= 2 {
|
|
331
|
+
note("BEGIN held timed native input")
|
|
332
|
+
fflush(nil)
|
|
333
|
+
}
|
|
334
|
+
try CraftTimedInput.synthesize(paths: paths, frame: frame)
|
|
335
|
+
let elapsed = ProcessInfo.processInfo.systemUptime - started
|
|
336
|
+
post("/save?name=timed-input-\(Int(started * 1000)).json", scheduled)
|
|
337
|
+
check(true, "timed native input: \(paths.count) paths, \(elapsed)s wall time")
|
|
338
|
+
} catch { check(false, "timed native input: \(error)") }
|
|
291
339
|
case "tapxy":
|
|
292
340
|
let xy = arg.split(separator: " ").compactMap { Double($0) }
|
|
293
341
|
if xy.count == 2 && xy.allSatisfy({ $0 >= 0 && $0 <= 1 }) {
|