craft-native 0.0.499 → 0.0.500

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.
@@ -297,10 +297,38 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
297
297
  post { onContentSizeChanged?.invoke() }
298
298
  }
299
299
 
300
+ /**
301
+ * How far the grouped tab actions reach over the end of this list, in
302
+ * pixels (#523). The last row is given that much room below it, so it can
303
+ * be brought clear of them; the rows above still scroll behind them, and
304
+ * the list's own padding and viewport are as they were.
305
+ */
306
+ private var floatingCover = 0
307
+
308
+ fun setFloatingCover(cover: Int) {
309
+ val next = if (horizontal) 0 else max(0, cover)
310
+ if (next == floatingCover) return
311
+ floatingCover = next
312
+ invalidateItemDecorations()
313
+ }
314
+
315
+ private val floatingCoverRoom = object : ItemDecoration() {
316
+ override fun getItemOffsets(outRect: android.graphics.Rect, view: View, parent: RecyclerView, state: State) {
317
+ val position = parent.getChildAdapterPosition(view)
318
+ val last = state.itemCount - 1
319
+ if (floatingCover <= 0 || horizontal || position == NO_POSITION || last < 0) return
320
+ val grid = layoutManager as? GridLayoutManager
321
+ val endsTheList = if (grid == null) position == last
322
+ else grid.spanSizeLookup.getSpanGroupIndex(position, grid.spanCount) == grid.spanSizeLookup.getSpanGroupIndex(last, grid.spanCount)
323
+ if (endsTheList) outRect.bottom = floatingCover
324
+ }
325
+ }
326
+
300
327
  init {
301
328
  adapter = listAdapter
302
329
  itemAnimator = null
303
330
  addOnScrollListener(scrollListener)
331
+ addItemDecoration(floatingCoverRoom)
304
332
  }
305
333
 
306
334
  fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit) {
@@ -387,11 +415,10 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
387
415
  }
388
416
 
389
417
  val seen = mutableMapOf<String, Int>()
418
+ val taken = mutableSetOf<String>()
390
419
  val next = nodes.mapIndexed { index, node ->
391
420
  val base = identity(node) ?: "index:$index"
392
- val occurrence = seen.getOrDefault(base, 0)
393
- seen[base] = occurrence + 1
394
- val unique = if (occurrence == 0) base else "$base#$occurrence"
421
+ val unique = uniqueIdentity(base, seen, taken)
395
422
  val role = node.optJSONObject("props")?.optString("listRole").orEmpty()
396
423
  Item(unique, node, node.toString(), role.isNotBlank() && role != "item")
397
424
  }.let { if (inverted) it.reversed() else it }
@@ -546,6 +573,27 @@ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
546
573
  endReached?.invoke()
547
574
  }
548
575
 
576
+ /**
577
+ * A row's identity, its own in this list.
578
+ *
579
+ * Rows that share a key are told apart by a suffix: `run`, then `run#1`.
580
+ * A row whose own key was `run#1` then had the identity of the second
581
+ * `run`, and with it the same stable id, which a RecyclerView requires to
582
+ * be unique; UIKit's list ended the process on the same rows. The suffix
583
+ * counts on until the identity is one no row has taken. A list with no
584
+ * such key has the identities it always had.
585
+ */
586
+ private fun uniqueIdentity(base: String, seen: MutableMap<String, Int>, taken: MutableSet<String>): String {
587
+ var occurrence = seen.getOrDefault(base, 0)
588
+ var identity = if (occurrence == 0) base else "$base#$occurrence"
589
+ while (!taken.add(identity)) {
590
+ occurrence += 1
591
+ identity = "$base#$occurrence"
592
+ }
593
+ seen[base] = occurrence + 1
594
+ return identity
595
+ }
596
+
549
597
  private fun identity(node: JSONObject): String? {
550
598
  val props = node.optJSONObject("props") ?: JSONObject()
551
599
  return listOf(node.optString("id"), node.optString("key"), props.optString("key"), props.optString("testID"))
@@ -1335,6 +1335,60 @@ internal class CraftNativeChrome(
1335
1335
  refreshColors()
1336
1336
  }
1337
1337
 
1338
+ /**
1339
+ * How far the grouped actions reach up from the tab bar, in pixels: their
1340
+ * height and their lift above it. Zero when they are not floating over
1341
+ * the screen (no group, a hidden bar, or actions that sit in the tab row).
1342
+ */
1343
+ internal fun floatingActionsReach(): Int =
1344
+ if (accessoryGroup.parent === body && accessoryGroup.visibility == View.VISIBLE && accessoryGroup.height > 0) (body.height - accessoryGroup.top).coerceAtLeast(0)
1345
+ else 0
1346
+
1347
+ private var floatingClearancePosted = false
1348
+ private var floatingClearanceGiven = false
1349
+
1350
+ /**
1351
+ * The grouped actions float over the trailing end of whatever scrolls
1352
+ * behind them, and nothing was reserved, so the last rows of a screen
1353
+ * could not be brought out from under them (#523). Every vertical scroll
1354
+ * view and list that reaches into them is given that much room at its
1355
+ * end; one that ends above them gets none. Measured after each layout,
1356
+ * where each of them now ends.
1357
+ */
1358
+ private fun clearFloatingActions() {
1359
+ val reach = floatingActionsReach()
1360
+ // Nothing floats and nothing was given room: no screen to walk.
1361
+ if (reach <= 0 && !floatingClearanceGiven) return
1362
+ floatingClearanceGiven = reach > 0
1363
+ val top = body.height - reach
1364
+ val origin = IntArray(2).also { body.getLocationInWindow(it) }
1365
+ val at = IntArray(2)
1366
+ fun cover(view: View): Int {
1367
+ if (reach <= 0 || !view.isLaidOut) return 0
1368
+ view.getLocationInWindow(at)
1369
+ return (at[1] - origin[1] + view.height - top).coerceIn(0, reach)
1370
+ }
1371
+ fun visit(view: View) {
1372
+ when (view) {
1373
+ is CraftNativeFlatList -> view.setFloatingCover(cover(view))
1374
+ is CraftNativeScrollView -> { view.setFloatingCover(cover(view)); visit(view.content) }
1375
+ is ViewGroup -> for (index in 0 until view.childCount) visit(view.getChildAt(index))
1376
+ }
1377
+ }
1378
+ visit(content)
1379
+ }
1380
+
1381
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
1382
+ super.onLayout(changed, left, top, right, bottom)
1383
+ if (floatingClearancePosted) return
1384
+ floatingClearancePosted = true
1385
+ // After this pass: giving a scroll view room asks for a layout.
1386
+ post {
1387
+ floatingClearancePosted = false
1388
+ clearFloatingActions()
1389
+ }
1390
+ }
1391
+
1338
1392
  /** The same actions and popup anchors survive switching between grouped and separate chrome. */
1339
1393
  private fun layoutAccessoryGroup(hidden: Boolean) {
1340
1394
  val layout = accessorySpec?.optString("layout").orEmpty()
@@ -3067,6 +3067,19 @@ internal class CraftNativeScrollView(context: Context) : SwipeRefreshLayout(cont
3067
3067
  }
3068
3068
  }
3069
3069
 
3070
+ /**
3071
+ * How far the grouped tab actions reach over the end of this scroll view,
3072
+ * in pixels (#523). Its content gets that much room at the end, so the
3073
+ * last of it can be brought clear of them; the rest still scrolls behind
3074
+ * them, in the viewport it had.
3075
+ */
3076
+ fun setFloatingCover(cover: Int) {
3077
+ val next = if (horizontalMode == true) 0 else max(0, cover)
3078
+ if (vertical.paddingBottom == next) return
3079
+ vertical.clipToPadding = false
3080
+ vertical.setPadding(vertical.paddingLeft, vertical.paddingTop, vertical.paddingRight, next)
3081
+ }
3082
+
3070
3083
  private var bounces = true
3071
3084
  private var alwaysBounceVertical = true
3072
3085
  private var alwaysBounceHorizontal = true
@@ -3585,6 +3598,17 @@ private class CraftNativeTextField(context: Context) : EditText(context) {
3585
3598
  CraftNativePointers.dispatch(this, event) { super.dispatchTouchEvent(it) }
3586
3599
 
3587
3600
  companion object { val CLEAR_ACTION_ID = View.generateViewId() }
3601
+ /**
3602
+ * `autoFocus` as this field was last drawn. Focus is taken when it turns
3603
+ * on, which mounting with it on is, and not on each render while it stays
3604
+ * on: that took the keyboard back whenever anything else changed (#511).
3605
+ */
3606
+ private var autoFocus = false
3607
+ fun autoFocusTurnedOn(wanted: Boolean): Boolean {
3608
+ val turnedOn = wanted && !autoFocus
3609
+ autoFocus = wanted
3610
+ return turnedOn
3611
+ }
3588
3612
  var clearButtonMode = "never"
3589
3613
  var onClearRequest: (() -> Unit)? = null
3590
3614
  private var clearPressed = false
@@ -6802,7 +6826,7 @@ class MainActivity : AppCompatActivity() {
6802
6826
  send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject().put("text", text.toString())))
6803
6827
  true
6804
6828
  }
6805
- if (craftNativeBoolean(props.opt("autoFocus")) && !isFocused) post {
6829
+ if (autoFocusTurnedOn(craftNativeBoolean(props.opt("autoFocus"))) && !isFocused) post {
6806
6830
  if (!isFocused) {
6807
6831
  requestFocus()
6808
6832
  (getSystemService(INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager)
@@ -60,7 +60,6 @@ export declare function exec(command: string, options?: ExecOptions): Promise<Ex
60
60
  */
61
61
  export interface SpawnOptions {
62
62
  cwd?: string;
63
- env?: Record<string, string>;
64
63
  /**
65
64
  * Run `command` as a command line through a shell (cmd.exe on Windows,
66
65
  * /bin/sh elsewhere) instead of executing it directly.
@@ -74,45 +73,41 @@ export interface SpawnOptions {
74
73
  */
75
74
  shell?: boolean;
76
75
  }
76
+ /**
77
+ * A process started through the native bridge.
78
+ *
79
+ * What native `shell.spawn` does, and so all this class offers: start a
80
+ * program, and kill it. The child inherits Craft's own standard output and
81
+ * error. Nothing it writes is delivered to the page, and its exit is not
82
+ * reported.
83
+ *
84
+ * `onStdout`, `onStderr`, `onExit` and `write` used to be declared here. No
85
+ * native bridge ever emitted the events behind them or accepted input, so a
86
+ * handler registered with them was never called. They are removed rather than
87
+ * left as methods that accept a callback and stay silent. To read a child's
88
+ * output, run it in your app's backend process and send the result to the
89
+ * page; `exec()` covers a command that finishes and returns its output whole.
90
+ *
91
+ * Windows and Linux desktop builds do not serve the `shell` namespace at all:
92
+ * there `start()` rejects.
93
+ */
77
94
  export declare class ChildProcess {
78
95
  private command;
79
96
  private args;
80
97
  private options;
81
98
  private processId;
82
- private onStdoutHandlers;
83
- private onStderrHandlers;
84
- private onExitHandlers;
85
- private domListeners;
86
- private exited;
87
99
  constructor(command: string, args?: string[], options?: SpawnOptions);
88
- private addDomListener;
89
- private removeDomListeners;
90
- /** Pid assigned by the native bridge, or null before start(). */
100
+ /** Identifier this process is registered under, or null before start(). */
91
101
  get pid(): string | null;
92
102
  /**
93
- * Start the process
103
+ * Start the process. Resolves once native has started it.
94
104
  */
95
105
  start(): Promise<void>;
96
106
  /**
97
- * Write to stdin
98
- */
99
- write(data: string): Promise<void>;
100
- /**
101
- * Kill the process
102
- */
103
- kill(signal?: string): Promise<void>;
104
- /**
105
- * Listen for stdout data
106
- */
107
- onStdout(handler: (data: string) => void): () => void;
108
- /**
109
- * Listen for stderr data
110
- */
111
- onStderr(handler: (data: string) => void): () => void;
112
- /**
113
- * Listen for exit
107
+ * Kill the process. Native sends the platform's default termination; there
108
+ * is no signal to choose.
114
109
  */
115
- onExit(handler: (code: number) => void): () => void;
110
+ kill(): Promise<void>;
116
111
  }
117
112
  /**
118
113
  * Spawn a child process
@@ -46,6 +46,12 @@ export declare const BridgeErrorCodes: {
46
46
  readonly PROTOCOL_MISMATCH: -8;
47
47
  /** Bridge transitioned from connected to disconnected with requests in flight */
48
48
  readonly DISCONNECTED: -9;
49
+ /**
50
+ * The native side received the request and refused or failed it. Not
51
+ * retried: asking again gets the same answer. `data.nativeCode` carries
52
+ * the bridge's own string code when it sent one.
53
+ */
54
+ readonly NATIVE_ERROR: -10;
49
55
  };
50
56
  export type BridgeErrorCode = typeof BridgeErrorCodes[keyof typeof BridgeErrorCodes];
51
57
  /**
@@ -136,12 +142,12 @@ export declare class NativeBridge extends EventEmitter {
136
142
  * BridgeError(PROTOCOL_MISMATCH) if the native side reports a version we
137
143
  * don't speak.
138
144
  *
139
- * This is **opt-in** — you must call it explicitly. It assumes the host
140
- * registers a `_handshake` handler; the Zig core's
141
- * `Bridge.registerDefaults()` does this for you. If you're embedding Craft
142
- * inside a host that hasn't wired `_handshake`, do not call this method
143
- * (the timeout path will reject with TIMEOUT, which is louder than a
144
- * silent mismatch but still a failure).
145
+ * This is **opt-in** — you must call it explicitly, and only against a
146
+ * host of your own that answers a `_handshake` request. Craft's own
147
+ * webview hosts do not: inside one, requests go through
148
+ * `window.craft.invoke`, which takes `namespace.action` names, and this
149
+ * call is rejected immediately instead of negotiating anything. Use
150
+ * `window.craft.capabilities` to ask a Craft host what it serves.
145
151
  */
146
152
  handshake(): Promise<{
147
153
  nativeVersion: number;
@@ -203,6 +209,24 @@ export declare class NativeBridge extends EventEmitter {
203
209
  * use that to fail fast instead of waiting for the request timeout.
204
210
  */
205
211
  private send;
212
+ /**
213
+ * Hand a request to `window.craft.invoke`, the dispatcher that
214
+ * `craft-bridge.js` injects into every Craft webview, and settle the
215
+ * pending entry with what native answers.
216
+ *
217
+ * This is the only path by which `request()` reaches native code. The
218
+ * `{id, type: 'request', method, params}` envelope this class builds is not
219
+ * something any Craft host parses: posted raw to the `craft` message
220
+ * handler it was dropped, and every `request()` waited out its timeout and
221
+ * its retries. The method name is used as written, `namespace.action`, so
222
+ * it has to be one the native bridge serves (`fs.readFile`,
223
+ * `shell.openUrl`, `dialog.openFile`); one it does not serve is rejected
224
+ * with `NATIVE_ERROR` straight away.
225
+ *
226
+ * Returns false when the injected bridge is not present, so the older
227
+ * transports below still get their turn in a host that provides one.
228
+ */
229
+ private sendThroughInjectedBridge;
206
230
  /**
207
231
  * Reject the pending entry corresponding to `message.id` with `error`,
208
232
  * if any. Used by `send()` when serialization or transport throws so
package/dist/cli.js CHANGED
@@ -1395,6 +1395,33 @@ Description: ${description || opts.name}
1395
1395
  }
1396
1396
  });
1397
1397
  }
1398
+ function rpmSpec(options) {
1399
+ return [
1400
+ "%global debug_package %{nil}",
1401
+ "%global _build_id_links none",
1402
+ "%global __strip /bin/true",
1403
+ "%global __os_install_post %{nil}",
1404
+ `Name: ${options.name}`,
1405
+ `Version: ${options.version}`,
1406
+ "Release: 1",
1407
+ `Summary: ${options.summary}`,
1408
+ "License: MIT",
1409
+ `Source0: ${options.name}`,
1410
+ ...options.requires ? [`Requires: ${options.requires}`] : [],
1411
+ "",
1412
+ "%description",
1413
+ options.description,
1414
+ "",
1415
+ "%install",
1416
+ "mkdir -p %{buildroot}/usr/bin",
1417
+ `install -m 755 %{SOURCE0} %{buildroot}/usr/bin/${options.name}`,
1418
+ "",
1419
+ "%files",
1420
+ `/usr/bin/${options.name}`,
1421
+ ""
1422
+ ].join(`
1423
+ `);
1424
+ }
1398
1425
  async function createRPM(opts) {
1399
1426
  return new Promise((resolve) => {
1400
1427
  try {
@@ -1427,26 +1454,17 @@ async function createRPM(opts) {
1427
1454
  return r;
1428
1455
  }).join(", ");
1429
1456
  copyFileSync(opts.binaryPath, join3(sourcesDir, sanitizedName));
1430
- const specContent = `Name: ${sanitizedName}
1431
- Version: ${opts.version}
1432
- Release: 1%{?dist}
1433
- Summary: ${summary}
1434
- License: MIT
1435
- Requires: ${requires}
1436
-
1437
- %description
1438
- ${description}
1439
-
1440
- %install
1441
- mkdir -p %{buildroot}/usr/bin
1442
- install -m 755 %{SOURCE0} %{buildroot}/usr/bin/${sanitizedName}
1443
-
1444
- %files
1445
- /usr/bin/${sanitizedName}
1446
- `;
1457
+ const specContent = rpmSpec({ name: sanitizedName, version: opts.version, summary, description, requires });
1447
1458
  const specPath = join3(specDir, `${opts.name.toLowerCase()}.spec`);
1448
1459
  writeFileSync4(specPath, specContent);
1449
1460
  const proc = spawn("rpmbuild", ["-bb", specPath]);
1461
+ let said = "";
1462
+ proc.stdout?.on("data", (chunk) => {
1463
+ said += chunk.toString();
1464
+ });
1465
+ proc.stderr?.on("data", (chunk) => {
1466
+ said += chunk.toString();
1467
+ });
1450
1468
  proc.on("close", (code) => {
1451
1469
  if (code === 0) {
1452
1470
  const rpmName = `${opts.name.toLowerCase()}-${opts.version}-1.x86_64.rpm`;
@@ -1458,7 +1476,9 @@ install -m 755 %{SOURCE0} %{buildroot}/usr/bin/${sanitizedName}
1458
1476
  resolve({ success: false, error: "Failed to copy built RPM" });
1459
1477
  }
1460
1478
  } else {
1461
- resolve({ success: false, error: `rpmbuild exited with code ${code}` });
1479
+ resolve({ success: false, error: `rpmbuild exited with code ${code}: ${said.trim().split(`
1480
+ `).slice(-12).join(`
1481
+ `)}` });
1462
1482
  }
1463
1483
  });
1464
1484
  proc.on("error", (err) => {
@@ -3246,7 +3266,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3246
3266
  `);
3247
3267
  }
3248
3268
  // package.json
3249
- var version = "0.0.499";
3269
+ var version = "0.0.500";
3250
3270
 
3251
3271
  // bin/cli.ts
3252
3272
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -801,7 +801,7 @@ export interface TextInputProps extends BaseProps {
801
801
  autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
802
802
  /** Auto correct */
803
803
  autoCorrect?: boolean;
804
- /** Auto focus */
804
+ /** Focus the input when it mounts, or when this turns on; not on each render. */
805
805
  autoFocus?: boolean;
806
806
  /** Secure text entry (password) */
807
807
  secureTextEntry?: boolean;