@qontinui/ui-bridge-native 0.4.0 → 0.6.1

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/index.mjs CHANGED
@@ -254,7 +254,18 @@ var NativeUIBridgeRegistry = class {
254
254
  return Array.from(this.elements.values());
255
255
  }
256
256
  /**
257
- * Get all registered elements that are visible and have a layout
257
+ * Get all registered elements that are visible AND have a measured layout.
258
+ *
259
+ * This is the *strict* visibility filter: requires `state.visible === true`
260
+ * AND `state.layout !== null`. Use this when you need both signals
261
+ * (e.g. coord-based tap, where you need an actual rect to hit-test against).
262
+ *
263
+ * Callers driving an LLM agent typically want
264
+ * {@link getMountedVisibleElements} instead — that variant accepts
265
+ * elements that *should* be on screen (visible:true) but whose first
266
+ * `onLayout` has not yet fired. Without it, a snapshot taken in the gap
267
+ * between mount and the first layout pass returns 0 elements on a fully
268
+ * populated screen.
258
269
  */
259
270
  getVisibleElements() {
260
271
  return this.getAllElements().filter((e) => {
@@ -262,6 +273,26 @@ var NativeUIBridgeRegistry = class {
262
273
  return state.visible && state.layout !== null;
263
274
  });
264
275
  }
276
+ /**
277
+ * Get all registered elements marked as visible (regardless of layout).
278
+ *
279
+ * Looser than {@link getVisibleElements}: includes elements whose
280
+ * `onLayout` callback has not yet fired (so `layout === null`). React
281
+ * Native's `onLayout` is async — there's a one-tick window after mount
282
+ * where elements are in the tree, the user *sees* them, but the
283
+ * registry hasn't received their measurements yet. Excluding those
284
+ * elements makes `getSnapshot?visibleOnly=true` return 0 on a populated
285
+ * screen, which is a worse failure mode than including a few elements
286
+ * we can't yet locate spatially.
287
+ *
288
+ * Used by `createSnapshot`'s `visibleOnly` filter and by the
289
+ * `getSnapshot` / `getElements` handlers. Each emitted element carries a
290
+ * `visibility` discriminator (`"visible"` / `"likely-visible"`) so
291
+ * agents that *do* require a known rect can branch downstream.
292
+ */
293
+ getMountedVisibleElements() {
294
+ return this.getAllElements().filter((e) => e.getState().visible);
295
+ }
265
296
  /**
266
297
  * Get elements registered on a specific route (for page-scoped filtering)
267
298
  */
@@ -536,7 +567,7 @@ var NativeUIBridgeRegistry = class {
536
567
  }
537
568
  return { currentRoute: null, segments: void 0 };
538
569
  })();
539
- let elements = options?.visibleOnly ? this.getVisibleElements() : this.getAllElements();
570
+ let elements = options?.visibleOnly ? this.getMountedVisibleElements() : this.getAllElements();
540
571
  if (options?.currentRouteOnly && resolvedRoute.currentRoute) {
541
572
  const currentRoute = resolvedRoute.currentRoute;
542
573
  elements = elements.filter((e) => e.registrationRoute === currentRoute);
@@ -545,16 +576,19 @@ var NativeUIBridgeRegistry = class {
545
576
  timestamp: Date.now(),
546
577
  elements: elements.map((e) => {
547
578
  const handlers = extractHandlerNames(e.props);
579
+ const state = e.getState();
580
+ const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
548
581
  return {
549
582
  id: e.id,
550
583
  type: e.type,
551
584
  label: e.label,
552
585
  identifier: e.getIdentifier(),
553
- state: e.getState(),
586
+ state,
554
587
  actions: e.actions,
555
588
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
556
589
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
557
- registrationRoute: e.registrationRoute
590
+ registrationRoute: e.registrationRoute,
591
+ visibility
558
592
  };
559
593
  }),
560
594
  components: this.getAllComponents().map((c) => ({
@@ -1948,22 +1982,25 @@ function createServerHandlers(registry, executor, config) {
1948
1982
  getElements: async (ctx) => {
1949
1983
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1950
1984
  const forRoute = ctx.query?.route || ctx.body?.route;
1951
- let allElements = visibleOnly ? registry.getVisibleElements() : registry.getAllElements();
1985
+ let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
1952
1986
  if (forRoute) {
1953
1987
  allElements = allElements.filter((e) => e.registrationRoute === forRoute);
1954
1988
  }
1955
1989
  const elements = allElements.map((e) => {
1956
1990
  const handlers = extractHandlerNames(e.props);
1991
+ const state = e.getState();
1992
+ const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1957
1993
  return {
1958
1994
  id: e.id,
1959
1995
  type: e.type,
1960
1996
  label: e.label,
1961
1997
  identifier: e.getIdentifier(),
1962
- state: e.getState(),
1998
+ state,
1963
1999
  actions: e.actions,
1964
2000
  customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1965
2001
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1966
- registrationRoute: e.registrationRoute
2002
+ registrationRoute: e.registrationRoute,
2003
+ visibility
1967
2004
  };
1968
2005
  });
1969
2006
  return success2({ elements });
@@ -2218,6 +2255,45 @@ function createServerHandlers(registry, executor, config) {
2218
2255
  "NOT_SUPPORTED"
2219
2256
  );
2220
2257
  },
2258
+ // Keep-awake (stub — apps should override via setKeepAwakeProvider, which
2259
+ // the provider wires when a keepAwakeProvider prop is supplied)
2260
+ keepAwake: async () => {
2261
+ return error2(
2262
+ "keep-awake not configured. Provide a keepAwakeProvider to UIBridgeNativeProvider.",
2263
+ "NOT_SUPPORTED"
2264
+ );
2265
+ },
2266
+ // AI helpers — runner-only endpoints that have no React Native analog.
2267
+ //
2268
+ // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
2269
+ // cheatsheet paths so callers (and the /manual-test skill in particular)
2270
+ // get a structured envelope explaining the gap rather than a confusing
2271
+ // HTTP 404. Each error message names an in-tree mobile replacement so
2272
+ // operators aren't left guessing what to use instead.
2273
+ aiForms: async () => {
2274
+ return error2(
2275
+ 'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
2276
+ "NOT_SUPPORTED"
2277
+ );
2278
+ },
2279
+ aiIdleStatus: async () => {
2280
+ return error2(
2281
+ 'ai/idle-status (page-idle signal) is runner-only; mobile React Native has no DOM-mutation / network-idle signals. Use the registry event stream (WS subscribe to "element:registered"/"element:stateChanged") or poll /control/snapshot for stability.',
2282
+ "NOT_SUPPORTED"
2283
+ );
2284
+ },
2285
+ aiChangeBufferEnable: async () => {
2286
+ return error2(
2287
+ 'ai/change-buffer (DOM mutation diff buffer) is runner-only; mobile React Native has no DOM. Use the WS event bridge (subscribe to "element:registered"/"element:unregistered"/"element:stateChanged") for change notifications.',
2288
+ "NOT_SUPPORTED"
2289
+ );
2290
+ },
2291
+ aiWaitForElement: async () => {
2292
+ return error2(
2293
+ 'POST /ai/wait-for-element is runner-only on mobile. Use the WS "waitForElement" JSON-RPC method or "waitForCondition" instead \u2014 same semantics, no HTTP polling overhead.',
2294
+ "NOT_SUPPORTED"
2295
+ );
2296
+ },
2221
2297
  // Meta / Introspection (stub — server constructor overrides with real route table)
2222
2298
  getMethods: async () => {
2223
2299
  return error2("Methods introspection not configured", "NOT_SUPPORTED");
@@ -2873,6 +2949,24 @@ var WS_ROUTES = [
2873
2949
  { pattern: "meta/methods", handler: "getMethods", httpMethods: ["GET"] },
2874
2950
  // AI helpers — mobile parity with the runner's `/ui-bridge/ai/*` family.
2875
2951
  { pattern: "ai/fill-form", handler: "fillForm", httpMethods: ["POST"] },
2952
+ // AI helpers — NOT_SUPPORTED stubs. These endpoints exist on the runner-side
2953
+ // UI Bridge surface (and the /manual-test skill's cheatsheet documents them
2954
+ // as if they're universally available). Mobile React Native has no DOM, so
2955
+ // the mobile bridge mounts explicit stubs so callers get a structured
2956
+ // NOT_SUPPORTED envelope (with a hint pointing at the mobile-native
2957
+ // replacement) instead of a confusing HTTP 404.
2958
+ { pattern: "ai/forms", handler: "aiForms", httpMethods: ["GET"] },
2959
+ { pattern: "ai/idle-status", handler: "aiIdleStatus", httpMethods: ["GET"] },
2960
+ {
2961
+ pattern: "ai/change-buffer/enable",
2962
+ handler: "aiChangeBufferEnable",
2963
+ httpMethods: ["POST"]
2964
+ },
2965
+ {
2966
+ pattern: "ai/wait-for-element",
2967
+ handler: "aiWaitForElement",
2968
+ httpMethods: ["POST"]
2969
+ },
2876
2970
  // Coord-based tap — synthesizes a press at the given screen coords by
2877
2971
  // matching against registered elements' layout rects.
2878
2972
  { pattern: "control/tap", handler: "tapAt", httpMethods: ["POST"] },
@@ -2891,6 +2985,12 @@ var WS_ROUTES = [
2891
2985
  httpMethods: ["POST"],
2892
2986
  requiresTestHooks: true
2893
2987
  },
2988
+ {
2989
+ pattern: "control/keep-awake",
2990
+ handler: "keepAwake",
2991
+ httpMethods: ["POST"],
2992
+ requiresTestHooks: true
2993
+ },
2894
2994
  // Observability — last-N console errors + network requests. testHooks-gated
2895
2995
  // because patching `console.error` / `fetch` is invasive in production.
2896
2996
  {
@@ -2939,11 +3039,16 @@ var HANDLER_DESCRIPTIONS = {
2939
3039
  diffBaseline: "Diff current UI against a saved baseline",
2940
3040
  getMethods: "List every handler name (legacy introspection)",
2941
3041
  fillForm: "Fill multiple inputs in one call: body { fields: [{elementId, value}] } \u2014 each field dispatches `type` + `clear:true`",
3042
+ aiForms: 'STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use /control/snapshot + filter type==="input" instead.',
3043
+ aiIdleStatus: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Subscribe to registry events over WS for change signals.",
3044
+ aiChangeBufferEnable: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use WS event subscriptions for change notifications.",
3045
+ aiWaitForElement: "STUB \u2014 runner-only; mobile returns NOT_SUPPORTED. Use the WS waitForElement / waitForCondition JSON-RPC methods.",
2942
3046
  tapAt: "Synthesize a press at given screen coords by matching registered element layout rects: body { x, y, action? }",
2943
3047
  getConsoleErrors: "TEST HOOK \u2014 last-N console.error/console.warn entries; ?since=<ms>&limit=<n>",
2944
3048
  getNetworkRequests: "TEST HOOK \u2014 last-N fetch/XHR entries with status + duration; ?since=<ms>&limit=<n>",
2945
3049
  pushModal: "TEST HOOK \u2014 push a modal onto the snapshot.modalStack via the registry ModalDetector",
2946
- dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector"
3050
+ dismissModal: "TEST HOOK \u2014 dismiss a modal by id from the snapshot.modalStack via the registry ModalDetector",
3051
+ keepAwake: "TEST HOOK \u2014 keep the device screen awake via the injected keepAwakeProvider: body { enabled: boolean, durationMs?: number }"
2947
3052
  };
2948
3053
  var ACTION_REFERENCE = [
2949
3054
  { name: "press", aliases: ["click"], params: "PressActionParams (optional position)" },
@@ -3211,6 +3316,35 @@ var NativeUIBridgeServer = class {
3211
3316
  }
3212
3317
  };
3213
3318
  }
3319
+ /**
3320
+ * Set a keep-awake provider for native screen-wake control.
3321
+ * This enables `control/keep-awake` and arms the auto-arm hook in
3322
+ * handleRequest (every request keeps the screen alive for 60s).
3323
+ */
3324
+ setKeepAwakeProvider(provider) {
3325
+ this.keepAwakeProvider = provider;
3326
+ this.handlers.keepAwake = async (ctx) => {
3327
+ const { enabled, durationMs } = ctx.body ?? {};
3328
+ if (typeof enabled !== "boolean") {
3329
+ return {
3330
+ success: false,
3331
+ error: "enabled must be boolean",
3332
+ code: "INVALID_REQUEST",
3333
+ timestamp: Date.now()
3334
+ };
3335
+ }
3336
+ if (enabled) {
3337
+ provider.request("ui-bridge", typeof durationMs === "number" ? durationMs : void 0);
3338
+ } else {
3339
+ provider.release("ui-bridge");
3340
+ }
3341
+ return {
3342
+ success: true,
3343
+ data: { enabled, durationMs: typeof durationMs === "number" ? durationMs : null },
3344
+ timestamp: Date.now()
3345
+ };
3346
+ };
3347
+ }
3214
3348
  /**
3215
3349
  * Start the HTTP server
3216
3350
  */
@@ -3803,6 +3937,12 @@ var NativeUIBridgeServer = class {
3803
3937
  if (request.method === "OPTIONS") {
3804
3938
  return { status: 204, headers, body: "" };
3805
3939
  }
3940
+ if (this.config.testHooks === true && this.keepAwakeProvider) {
3941
+ try {
3942
+ this.keepAwakeProvider.request("auto-arm", 6e4);
3943
+ } catch {
3944
+ }
3945
+ }
3806
3946
  try {
3807
3947
  const response = await this.routeRequest(request);
3808
3948
  return {
@@ -4703,6 +4843,7 @@ function UIBridgeNativeProvider({
4703
4843
  serverAdapter,
4704
4844
  navigationProvider,
4705
4845
  screenshotProvider,
4846
+ keepAwakeProvider,
4706
4847
  routeProvider,
4707
4848
  cloudRelayConfig,
4708
4849
  enableMdnsAnnounce,
@@ -4757,6 +4898,9 @@ function UIBridgeNativeProvider({
4757
4898
  if (screenshotProvider) {
4758
4899
  server.setScreenshotProvider(screenshotProvider);
4759
4900
  }
4901
+ if (keepAwakeProvider) {
4902
+ server.setKeepAwakeProvider(keepAwakeProvider);
4903
+ }
4760
4904
  if (routeProvider) {
4761
4905
  server.setRouteProvider(routeProvider);
4762
4906
  }
@@ -4810,6 +4954,7 @@ function UIBridgeNativeProvider({
4810
4954
  serverAdapter,
4811
4955
  navigationProvider,
4812
4956
  screenshotProvider,
4957
+ keepAwakeProvider,
4813
4958
  routeProvider
4814
4959
  ]);
4815
4960
  const stopServer = useCallback(() => {
@@ -4843,6 +4988,7 @@ function UIBridgeNativeProvider({
4843
4988
  });
4844
4989
  if (navigationProvider) bareServer.setNavigationProvider(navigationProvider);
4845
4990
  if (screenshotProvider) bareServer.setScreenshotProvider(screenshotProvider);
4991
+ if (keepAwakeProvider) bareServer.setKeepAwakeProvider(keepAwakeProvider);
4846
4992
  if (routeProvider) bareServer.setRouteProvider(routeProvider);
4847
4993
  serverRef.current = bareServer;
4848
4994
  } catch (err) {
@@ -5017,6 +5163,24 @@ function flattenStateStyles(stateStyles) {
5017
5163
  if (stateStyles.disabled != null) result.disabled = flattenStyle(stateStyles.disabled);
5018
5164
  return Object.keys(result).length > 0 ? result : void 0;
5019
5165
  }
5166
+ var warnedDeadButtonIds = /* @__PURE__ */ new Set();
5167
+ function scheduleDeadButtonWarning(bridge, id, type) {
5168
+ if (type !== "button" && type !== "pressable") return;
5169
+ if (warnedDeadButtonIds.has(id)) return;
5170
+ if (!bridge) return;
5171
+ setTimeout(() => {
5172
+ if (warnedDeadButtonIds.has(id)) return;
5173
+ const element = bridge.registry.getElement(id);
5174
+ if (!element) return;
5175
+ const props = element.props ?? {};
5176
+ const hasPress = typeof props.onPress === "function" || typeof props.onPressIn === "function" || typeof props.onResponderRelease === "function";
5177
+ if (hasPress) return;
5178
+ warnedDeadButtonIds.add(id);
5179
+ console.warn(
5180
+ `[UI Bridge] Registered \`${type}\` element "${id}" without an \`onPress\` handler \u2014 UI Bridge \`press\` actions on this element will fail with "No press handler found on element". Use \`useUIElementWithProps\` and \`captureProps({onPress})\` instead.`
5181
+ );
5182
+ }, 0);
5183
+ }
5020
5184
  function useUIElement(options) {
5021
5185
  const bridge = useUIBridgeNativeOptional();
5022
5186
  const ref = useRef(null);
@@ -5063,6 +5227,7 @@ function useUIElement(options) {
5063
5227
  registeredRef.current = true;
5064
5228
  registeredIdRef.current = id;
5065
5229
  setRegistered(true);
5230
+ scheduleDeadButtonWarning(bridge, id, type);
5066
5231
  }, [bridge, id, type, label, actions, customActions, treePath, style, stateStylesProp]);
5067
5232
  const unregister = useCallback(() => {
5068
5233
  if (!bridge || !registeredRef.current) return;
@@ -5122,30 +5287,42 @@ function useUIElement(options) {
5122
5287
  const node = ref.current;
5123
5288
  if (!node || typeof node.measureInWindow !== "function") return;
5124
5289
  let cancelled = false;
5125
- node.measureInWindow((pageX, pageY, w, h) => {
5126
- if (cancelled) return;
5127
- if (w <= 0 || h <= 0) return;
5128
- const newLayout = {
5129
- x: pageX,
5130
- y: pageY,
5131
- width: w,
5132
- height: h,
5133
- pageX,
5134
- pageY
5135
- };
5136
- setLayout(newLayout);
5137
- const newState = {
5138
- mounted: true,
5139
- visible: true,
5140
- enabled: true,
5141
- focused: false,
5142
- layout: newLayout
5143
- };
5144
- bridge.registry.updateElementState(id, newState);
5145
- onStateChangeRef.current?.(newState);
5146
- });
5290
+ let pendingTimer = null;
5291
+ const backoffMs = [50, 200, 500, 1e3, 2500];
5292
+ let attempt = 0;
5293
+ const tryMeasure = () => {
5294
+ if (cancelled || !node.measureInWindow) return;
5295
+ node.measureInWindow((pageX, pageY, w, h) => {
5296
+ if (cancelled) return;
5297
+ if (w > 0 && h > 0) {
5298
+ const newLayout = {
5299
+ x: pageX,
5300
+ y: pageY,
5301
+ width: w,
5302
+ height: h,
5303
+ pageX,
5304
+ pageY
5305
+ };
5306
+ setLayout(newLayout);
5307
+ const newState = {
5308
+ mounted: true,
5309
+ visible: true,
5310
+ enabled: true,
5311
+ focused: false,
5312
+ layout: newLayout
5313
+ };
5314
+ bridge.registry.updateElementState(id, newState);
5315
+ onStateChangeRef.current?.(newState);
5316
+ return;
5317
+ }
5318
+ if (attempt >= backoffMs.length) return;
5319
+ pendingTimer = setTimeout(tryMeasure, backoffMs[attempt++]);
5320
+ });
5321
+ };
5322
+ tryMeasure();
5147
5323
  return () => {
5148
5324
  cancelled = true;
5325
+ if (pendingTimer != null) clearTimeout(pendingTimer);
5149
5326
  };
5150
5327
  }, [bridge, registered, id]);
5151
5328
  const registeredHandlerKeysRef = useRef([]);
@@ -5638,6 +5815,31 @@ var UI_BRIDGE_NATIVE_ROUTES = {
5638
5815
  path: "/ui-bridge/ai/fill-form",
5639
5816
  description: "Fill multiple input elements in a single call"
5640
5817
  },
5818
+ // AI helpers (NOT_SUPPORTED stubs) — these endpoints exist on the runner-side
5819
+ // UI Bridge surface but have no analog on mobile React Native. The mobile
5820
+ // bridge registers explicit stubs so callers get a structured NOT_SUPPORTED
5821
+ // envelope instead of a confusing HTTP 404. The mobile bridge's snapshot +
5822
+ // `/control/find` already cover what an operator would reach for these for.
5823
+ AI_FORMS: {
5824
+ method: "GET",
5825
+ path: "/ui-bridge/ai/forms",
5826
+ description: "Discover forms (runner-only; mobile returns NOT_SUPPORTED)"
5827
+ },
5828
+ AI_IDLE_STATUS: {
5829
+ method: "GET",
5830
+ path: "/ui-bridge/ai/idle-status",
5831
+ description: "Get page-idle signal (runner-only; mobile returns NOT_SUPPORTED)"
5832
+ },
5833
+ AI_CHANGE_BUFFER_ENABLE: {
5834
+ method: "POST",
5835
+ path: "/ui-bridge/ai/change-buffer/enable",
5836
+ description: "Enable DOM change buffer (runner-only; mobile returns NOT_SUPPORTED)"
5837
+ },
5838
+ AI_WAIT_FOR_ELEMENT: {
5839
+ method: "POST",
5840
+ path: "/ui-bridge/ai/wait-for-element",
5841
+ description: "Wait for element predicate via HTTP (runner-only; mobile returns NOT_SUPPORTED \u2014 use WS waitForElement instead)"
5842
+ },
5641
5843
  // Test hooks — drive ModalDetector state from outside the React tree
5642
5844
  // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
5643
5845
  // calls that components normally make in-process.