@lingxia/bridge 0.9.0 → 0.11.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.
@@ -1,7 +1,7 @@
1
1
  import { BRIDGE_ERROR } from "./types";
2
2
  import { toBridgeError, toNativeError } from "./invocation";
3
3
  import { installNativeComponentCoverageMonitor } from "./nativecomponents/coverage-monitor";
4
- import { BRIDGE_CONFIG, getCommunicationMethod, getPlatformOS, isAndroid, isHarmony, isIOS, isMacOS, isDesktop, } from "./runtime-env";
4
+ import { BRIDGE_CONFIG, getCommunicationMethod, getPlatformOS, isAndroid, isHarmony, isIOS, isMacOS, isWindows, isDesktop, isApple, isDevSession, isRunner, } from "./runtime-env";
5
5
  const NATIVE_HANDLER_NAME = "LingXia";
6
6
  const GLOBAL_RECEIVER_NAME = "__LingXiaRecvMessage";
7
7
  const DEFAULT_TIMEOUT_MS = 5000;
@@ -10,6 +10,7 @@ const HANDSHAKE_MAX_RETRIES = 3;
10
10
  const LOG_PREFIX = "[LX.Bridge]";
11
11
  const MESSAGE_PORT_TYPE = "messageport";
12
12
  const JS_INTERFACE_TYPE = "jsinterface";
13
+ const WEB_MESSAGE_TYPE = "webmessage";
13
14
  const OUTBOX_LIMIT = 256;
14
15
  const APPLE_DOWNSTREAM_URL = BRIDGE_CONFIG.appleDownstreamURL;
15
16
  const APPLE_RECONNECT_BASE_MS = 200;
@@ -64,7 +65,18 @@ function activateReceiver(receiver) {
64
65
  function isDebugEnabled(flag) {
65
66
  return debugFlags.all || debugFlags[flag];
66
67
  }
68
+ // `log` is the bridge's own protocol/lifecycle trace. Native log capture
69
+ // forwards whatever the page emits to `console`, so the bridge itself decides
70
+ // whether to surface this framework chatter: only in a `lingxia dev` session
71
+ // (or when a debug flag is set). Shipped apps stay quiet, leaving the captured
72
+ // stream to the page's own output plus bridge warnings/errors.
67
73
  function log(...args) {
74
+ if (!isDevSession() &&
75
+ !debugFlags.all &&
76
+ !debugFlags.proto &&
77
+ !debugFlags.data) {
78
+ return;
79
+ }
68
80
  console.log(LOG_PREFIX, ...args);
69
81
  }
70
82
  function warn(...args) {
@@ -111,6 +123,17 @@ let appleDownstreamTask = null;
111
123
  let appleDownstreamAbortController = null;
112
124
  let appleReconnectTimer = null;
113
125
  let appleReconnectDelayMs = APPLE_RECONNECT_BASE_MS;
126
+ let appleDownstreamDisposed = false;
127
+ // Highest transport frame seq processed. Sent as `?from=` on reconnect so the
128
+ // host replays the gap; survives reconnects so a WebKit-replaced stream resumes
129
+ // without losing frames or tearing down the bridge session.
130
+ let appleLastFrameSeq = 0;
131
+ // Liveness tracking: the host heartbeats an idle connection, so a gap longer
132
+ // than a couple of heartbeats means the connection is silently dead and we
133
+ // reconnect proactively instead of waiting for a read error that may never come.
134
+ const APPLE_DOWNSTREAM_STALE_MS = 35000;
135
+ let appleLastFrameAt = 0;
136
+ let appleWatchdogTimer = null;
114
137
  const portInitState = {
115
138
  listenerInstalled: false,
116
139
  promise: null,
@@ -154,6 +177,23 @@ function clearAppleReconnectTimer() {
154
177
  appleReconnectTimer = null;
155
178
  }
156
179
  }
180
+ function disposeAppleDownstream(reason) {
181
+ if (!useAppleDownstreamTransport() || appleDownstreamDisposed)
182
+ return;
183
+ appleDownstreamDisposed = true;
184
+ clearAppleReconnectTimer();
185
+ if (appleWatchdogTimer !== null) {
186
+ clearInterval(appleWatchdogTimer);
187
+ appleWatchdogTimer = null;
188
+ }
189
+ if (typeof document !== "undefined") {
190
+ document.removeEventListener("visibilitychange", handleAppleVisibilityChange);
191
+ }
192
+ appleForcedReconnectPending = false;
193
+ appleDownstreamAbortController?.abort();
194
+ appleDownstreamConnected = false;
195
+ resetHandshakeState(reason, true);
196
+ }
157
197
  function closeActiveChannelsFromTransport(reason) {
158
198
  for (const [id, channel] of Array.from(activeChannels.entries())) {
159
199
  activeChannels.delete(id);
@@ -195,7 +235,7 @@ function processAppleDownstreamBuffer(buffer) {
195
235
  if (!line)
196
236
  continue;
197
237
  try {
198
- handleIncomingMessage(JSON.parse(line));
238
+ handleAppleDownstreamFrame(JSON.parse(line));
199
239
  }
200
240
  catch (e) {
201
241
  warn("Apple downstream parse error:", e, line);
@@ -203,13 +243,49 @@ function processAppleDownstreamBuffer(buffer) {
203
243
  }
204
244
  return remaining;
205
245
  }
246
+ // Unwrap the transport envelope: {"lxff":seq,"m":<message>} carries a business
247
+ // message with its frame seq; {"lxreset":true} means the host cannot replay our
248
+ // resume point and we must re-handshake.
249
+ function handleAppleDownstreamFrame(frame) {
250
+ if (!frame || typeof frame !== "object")
251
+ return;
252
+ // Any frame — data, heartbeat, or reset — proves the connection is alive.
253
+ appleLastFrameAt = Date.now();
254
+ const record = frame;
255
+ if (record.lxshutdown === true) {
256
+ disposeAppleDownstream("Apple downstream closed");
257
+ return;
258
+ }
259
+ if (record.lxhb !== undefined)
260
+ return; // heartbeat: liveness only, nothing to dispatch
261
+ if (record.lxreset === true) {
262
+ appleLastFrameSeq = 0;
263
+ resetHandshakeState("Apple downstream reset", true);
264
+ startHandshake();
265
+ return;
266
+ }
267
+ if (typeof record.lxff === "number") {
268
+ // Replayed frames after a reconnect can repeat the last-seen seq; ignore
269
+ // anything we have already processed so the session is not double-fed.
270
+ if (record.lxff <= appleLastFrameSeq)
271
+ return;
272
+ appleLastFrameSeq = record.lxff;
273
+ handleIncomingMessage(record.m);
274
+ return;
275
+ }
276
+ // Legacy/un-enveloped line (e.g. a bare keepalive); pass through.
277
+ handleIncomingMessage(frame);
278
+ }
206
279
  async function runAppleDownstream() {
207
280
  if (!APPLE_DOWNSTREAM_URL) {
208
281
  throw new Error("Apple downstream URL is not configured");
209
282
  }
210
283
  const controller = new AbortController();
211
284
  appleDownstreamAbortController = controller;
212
- const response = await fetch(APPLE_DOWNSTREAM_URL, {
285
+ // Resume from the last frame we saw so the host replays the gap on reconnect.
286
+ const separator = APPLE_DOWNSTREAM_URL.includes("?") ? "&" : "?";
287
+ const url = `${APPLE_DOWNSTREAM_URL}${separator}from=${appleLastFrameSeq}`;
288
+ const response = await fetch(url, {
213
289
  method: "GET",
214
290
  cache: "no-store",
215
291
  headers: { Accept: "application/x-ndjson" },
@@ -218,11 +294,16 @@ async function runAppleDownstream() {
218
294
  if (!response.ok) {
219
295
  throw new Error(`Apple downstream HTTP ${response.status}`);
220
296
  }
297
+ if (response.headers.get("X-LingXia-Bridge-Shutdown") === "1") {
298
+ disposeAppleDownstream("Apple downstream closed");
299
+ return;
300
+ }
221
301
  if (!response.body) {
222
302
  throw new Error("Apple downstream response body unavailable");
223
303
  }
224
304
  appleDownstreamConnected = true;
225
305
  appleReconnectDelayMs = APPLE_RECONNECT_BASE_MS;
306
+ appleLastFrameAt = Date.now();
226
307
  if (isDebugEnabled("proto"))
227
308
  log("Apple downstream connected");
228
309
  startHandshake();
@@ -241,7 +322,7 @@ async function runAppleDownstream() {
241
322
  const tail = buffered.trim();
242
323
  if (tail) {
243
324
  try {
244
- handleIncomingMessage(JSON.parse(tail));
325
+ handleAppleDownstreamFrame(JSON.parse(tail));
245
326
  }
246
327
  catch (e) {
247
328
  warn("Apple downstream trailing parse error:", e, tail);
@@ -260,13 +341,17 @@ async function runAppleDownstream() {
260
341
  catch { }
261
342
  }
262
343
  }
263
- function scheduleAppleDownstreamReconnect(reason) {
264
- if (!useAppleDownstreamTransport())
344
+ function scheduleAppleDownstreamReconnect(reason, immediate = false) {
345
+ if (!useAppleDownstreamTransport() || appleDownstreamDisposed)
265
346
  return;
266
347
  clearAppleReconnectTimer();
267
- const delay = appleReconnectDelayMs;
268
- appleReconnectDelayMs = Math.min(appleReconnectDelayMs * 2, APPLE_RECONNECT_MAX_MS);
269
- const message = `Apple downstream disconnected, retrying in ${delay}ms: ${reason}`;
348
+ const delay = immediate ? 0 : appleReconnectDelayMs;
349
+ if (!immediate) {
350
+ appleReconnectDelayMs = Math.min(appleReconnectDelayMs * 2, APPLE_RECONNECT_MAX_MS);
351
+ }
352
+ const message = immediate
353
+ ? `Apple downstream completed, reconnecting immediately: ${reason}`
354
+ : `Apple downstream disconnected, retrying in ${delay}ms: ${reason}`;
270
355
  if (delay <= APPLE_RECONNECT_BASE_MS) {
271
356
  log(message);
272
357
  }
@@ -279,12 +364,16 @@ function scheduleAppleDownstreamReconnect(reason) {
279
364
  }, delay);
280
365
  }
281
366
  function ensureAppleDownstream() {
282
- if (!useAppleDownstreamTransport())
367
+ if (!useAppleDownstreamTransport() || appleDownstreamDisposed)
283
368
  return;
284
369
  if (appleDownstreamTask)
285
370
  return;
286
371
  clearAppleReconnectTimer();
372
+ let completedCleanly = false;
287
373
  appleDownstreamTask = runAppleDownstream()
374
+ .then(() => {
375
+ completedCleanly = true;
376
+ })
288
377
  .catch((e) => {
289
378
  if (appleDownstreamAbortController?.signal.aborted)
290
379
  return;
@@ -294,13 +383,72 @@ function ensureAppleDownstream() {
294
383
  const aborted = appleDownstreamAbortController?.signal.aborted ?? false;
295
384
  appleDownstreamTask = null;
296
385
  appleDownstreamAbortController = null;
297
- const wasConnected = appleDownstreamConnected;
298
386
  appleDownstreamConnected = false;
299
- resetHandshakeState("Apple downstream closed", wasConnected);
300
- if (!aborted)
301
- scheduleAppleDownstreamReconnect("stream closed");
387
+ // A dropped transport is not a dead session: reconnect resumes from the
388
+ // last seq and the host replays the gap, so the handshake and in-flight
389
+ // streams stay intact. Only a host-sent reset (handled on the frame path)
390
+ // or a real abort tears things down.
391
+ if (!aborted && !appleDownstreamDisposed) {
392
+ // Native intentionally completes bootstrap/replay responses to flush
393
+ // WebKit. Reconnect without the failure backoff so a live stream can
394
+ // catch up and settle onto a long-lived downstream.
395
+ scheduleAppleDownstreamReconnect("stream closed", completedCleanly);
396
+ }
302
397
  });
303
398
  }
399
+ // Drop the current downstream and reconnect, exactly as WebKit does when it
400
+ // replaces the streaming fetch. The reconnect carries the real `from=<lastSeq>`,
401
+ // so nothing is lost. Driven by the staleness watchdog and the foreground
402
+ // handler, and exposed as a dev hook for repro harnesses. A no-op off Apple.
403
+ let appleForcedReconnectPending = false;
404
+ function forceDownstreamReconnect() {
405
+ if (!useAppleDownstreamTransport() || appleDownstreamDisposed)
406
+ return;
407
+ // Coalesce a burst of triggers (rapid foreground toggles, repeated calls)
408
+ // into a single reconnect so they cannot stack concurrent fetches.
409
+ if (appleForcedReconnectPending)
410
+ return;
411
+ appleForcedReconnectPending = true;
412
+ const task = appleDownstreamTask;
413
+ appleDownstreamAbortController?.abort();
414
+ const reconnect = () => {
415
+ appleForcedReconnectPending = false;
416
+ ensureAppleDownstream();
417
+ };
418
+ if (task)
419
+ task.finally(reconnect);
420
+ else
421
+ reconnect();
422
+ }
423
+ // A silently dead connection (half-open socket) never surfaces a read error, so
424
+ // poll for heartbeat staleness and reconnect when the host has gone quiet.
425
+ function startAppleWatchdog() {
426
+ if (!useAppleDownstreamTransport() ||
427
+ appleDownstreamDisposed ||
428
+ appleWatchdogTimer !== null)
429
+ return;
430
+ appleWatchdogTimer = setInterval(() => {
431
+ // No `connected` guard: a reconnect that hung before delivering a frame
432
+ // also goes stale, and forcing a reconnect aborts and retries it.
433
+ if (appleLastFrameAt === 0)
434
+ return;
435
+ if (Date.now() - appleLastFrameAt > APPLE_DOWNSTREAM_STALE_MS) {
436
+ warn("Apple downstream stale (no heartbeat), forcing reconnect");
437
+ forceDownstreamReconnect();
438
+ }
439
+ }, 5000);
440
+ }
441
+ // WebKit suspends/throttles a backgrounded webview and may tear the stream
442
+ // down; reconnect the moment it returns so the UI is never left stale.
443
+ function handleAppleVisibilityChange() {
444
+ if (document.visibilityState === "visible")
445
+ forceDownstreamReconnect();
446
+ }
447
+ function installAppleForegroundReconnect() {
448
+ if (!useAppleDownstreamTransport() || typeof document === "undefined")
449
+ return;
450
+ document.addEventListener("visibilitychange", handleAppleVisibilityChange);
451
+ }
304
452
  function getMessagePort() {
305
453
  if (messagePort)
306
454
  return Promise.resolve(messagePort);
@@ -345,7 +493,8 @@ function postToNative(message) {
345
493
  messagePort.postMessage(messageString);
346
494
  return;
347
495
  }
348
- if (communicationMethod === JS_INTERFACE_TYPE &&
496
+ if ((communicationMethod === JS_INTERFACE_TYPE ||
497
+ communicationMethod === WEB_MESSAGE_TYPE) &&
349
498
  window.LingXiaProxy?.postMessage) {
350
499
  window.LingXiaProxy.postMessage(messageString);
351
500
  return;
@@ -378,6 +527,9 @@ function createListenerBuckets() {
378
527
  function createStreamHandle(id, cancelFn) {
379
528
  const listeners = createListenerBuckets();
380
529
  let done = false;
530
+ // Only buffer for the async iterator; long-lived streams consumed via
531
+ // .on("data") would otherwise accumulate payloads forever.
532
+ let iteratorRequested = false;
381
533
  let resolveResult = () => { };
382
534
  let rejectResult = () => { };
383
535
  const pendingData = [];
@@ -399,6 +551,7 @@ function createStreamHandle(id, cancelFn) {
399
551
  return this;
400
552
  },
401
553
  [Symbol.asyncIterator]() {
554
+ iteratorRequested = true;
402
555
  return {
403
556
  next() {
404
557
  if (pendingData.length > 0) {
@@ -427,7 +580,7 @@ function createStreamHandle(id, cancelFn) {
427
580
  if (pendingReads.length > 0) {
428
581
  pendingReads.shift().resolve({ done: false, value: payload });
429
582
  }
430
- else {
583
+ else if (iteratorRequested) {
431
584
  pendingData.push(payload);
432
585
  }
433
586
  for (const listener of listeners.data) {
@@ -481,6 +634,9 @@ function createChannel(id, sendFn, closeFn) {
481
634
  let open = false;
482
635
  let outboundSeq = 0;
483
636
  let closed = false;
637
+ // Only buffer for the async iterator or pre-first-listener replay;
638
+ // otherwise a listener-driven channel would accumulate payloads forever.
639
+ let iteratorRequested = false;
484
640
  const pendingData = [];
485
641
  const pendingReads = [];
486
642
  const channel = {
@@ -496,8 +652,24 @@ function createChannel(id, sendFn, closeFn) {
496
652
  sendFn(payload, outboundSeq++);
497
653
  },
498
654
  on(event, listener) {
499
- if (event === "data")
655
+ if (event === "data") {
500
656
  listeners.data.add(listener);
657
+ // Flush data buffered before this listener attached. The host can send
658
+ // ch.data immediately after the open ack, before the consumer (e.g. a
659
+ // wrapped channel's onMessage) attaches its listener; without this those
660
+ // early events would sit in pendingData and never reach the listener.
661
+ if (pendingData.length > 0) {
662
+ const buffered = pendingData.splice(0);
663
+ for (const payload of buffered) {
664
+ try {
665
+ listener(payload);
666
+ }
667
+ catch (e) {
668
+ warn("Channel listener failed:", e);
669
+ }
670
+ }
671
+ }
672
+ }
501
673
  if (event === "close")
502
674
  listeners.close.add(listener);
503
675
  if (event === "error")
@@ -505,6 +677,7 @@ function createChannel(id, sendFn, closeFn) {
505
677
  return this;
506
678
  },
507
679
  [Symbol.asyncIterator]() {
680
+ iteratorRequested = true;
508
681
  return {
509
682
  next() {
510
683
  if (pendingData.length > 0) {
@@ -536,7 +709,7 @@ function createChannel(id, sendFn, closeFn) {
536
709
  if (pendingReads.length > 0) {
537
710
  pendingReads.shift().resolve({ done: false, value: payload });
538
711
  }
539
- else {
712
+ else if (iteratorRequested || listeners.data.size === 0) {
540
713
  pendingData.push(payload);
541
714
  }
542
715
  for (const listener of listeners.data) {
@@ -620,10 +793,19 @@ function isTransportReady() {
620
793
  if (useAppleDownstreamTransport())
621
794
  return appleDownstreamConnected;
622
795
  return (communicationMethod === "webkit" ||
623
- communicationMethod === JS_INTERFACE_TYPE);
796
+ communicationMethod === JS_INTERFACE_TYPE ||
797
+ communicationMethod === WEB_MESSAGE_TYPE);
624
798
  }
625
799
  function canSendAppMessages() {
626
- return isTransportReady() && handshakeDone;
800
+ if (!handshakeDone)
801
+ return false;
802
+ // Apple upstream posts through WKScriptMessageHandler independently of the
803
+ // downstream fetch. During a replay reconnect, native retains responses by
804
+ // sequence, so treating that short gap as an unsendable session can strand
805
+ // requests in the outbox with no second `ready` event to flush them.
806
+ if (useAppleDownstreamTransport())
807
+ return true;
808
+ return isTransportReady();
627
809
  }
628
810
  function rejectPendingRequest(reqId, err) {
629
811
  const info = pendingReq.get(reqId);
@@ -1012,7 +1194,15 @@ function handleIncomingMessage(msg) {
1012
1194
  if (pendingReq.has(message.id)) {
1013
1195
  const req = pendingReq.get(message.id);
1014
1196
  if (req?.mode === "stream" && req.stream) {
1015
- armRequestTimer(message.id);
1197
+ // The first frame proves the handler is live. Long-lived streams
1198
+ // (bookmarks.watch, proxy.watch) are idle between pushes, so the
1199
+ // request timeout must not treat silence as death — disarm it for
1200
+ // good instead of re-arming per frame.
1201
+ if (req.timerId !== null) {
1202
+ clearTimeout(req.timerId);
1203
+ req.timerId = null;
1204
+ }
1205
+ req.timeoutMs = 0;
1016
1206
  req.stream._emitData(message.payload);
1017
1207
  }
1018
1208
  else {
@@ -1090,8 +1280,10 @@ function handleIncomingMessage(msg) {
1090
1280
  case "ch.close": {
1091
1281
  const pendingChannel = pendingChannels.get(message.id);
1092
1282
  if (pendingChannel) {
1093
- pendingChannels.delete(message.id);
1094
- pendingChannel.channel._emitClose(message.code, message.reason);
1283
+ rejectPendingChannel(message.id, {
1284
+ code: message.code ?? BRIDGE_ERROR.STREAM_CLOSED,
1285
+ message: message.reason ?? "Channel closed before opening",
1286
+ });
1095
1287
  return;
1096
1288
  }
1097
1289
  const channel = activeChannels.get(message.id);
@@ -1437,7 +1629,10 @@ export const LingXiaBridge = {
1437
1629
  isIOS,
1438
1630
  isAndroid,
1439
1631
  isMacOS,
1632
+ isWindows,
1440
1633
  isDesktop,
1634
+ isApple,
1635
+ isRunner,
1441
1636
  getOS: getPlatformOS,
1442
1637
  },
1443
1638
  dom: {
@@ -1578,19 +1773,25 @@ export function initBridge() {
1578
1773
  activateReceiver(LingXiaBridge._receiveEvaluateMessage);
1579
1774
  if (useAppleDownstreamTransport()) {
1580
1775
  ensureAppleDownstream();
1776
+ startAppleWatchdog();
1777
+ installAppleForegroundReconnect();
1581
1778
  }
1582
1779
  else if (communicationMethod === MESSAGE_PORT_TYPE) {
1583
1780
  installMessagePortInitListener();
1584
1781
  getMessagePort().catch((e) => warn("Port init failed:", e));
1585
1782
  }
1586
1783
  else if (communicationMethod === "webkit" ||
1587
- communicationMethod === JS_INTERFACE_TYPE) {
1784
+ communicationMethod === JS_INTERFACE_TYPE ||
1785
+ communicationMethod === WEB_MESSAGE_TYPE) {
1588
1786
  startHandshake();
1589
1787
  }
1590
1788
  else {
1591
1789
  warn("Unknown method");
1592
1790
  }
1593
1791
  window.LingXiaBridge = LingXiaBridge;
1792
+ // Dev hook so repro/test pages can simulate a WebKit stream replacement.
1793
+ window.__lxForceDownstreamReconnect =
1794
+ forceDownstreamReconnect;
1594
1795
  installNativeComponentCoverageMonitor({
1595
1796
  os: getPlatformOS(),
1596
1797
  send: sendNativeComponentMessage,
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export { LingXiaBridge, channel, initBridge, invoke, notify, stream, } from './bridge';
9
9
  export { isNativeError } from './invocation';
10
+ export { getDisplayLanguage } from './runtime-env';
10
11
  export { renderErrorUI, hasError, getErrorInfo } from './error';
11
12
  export { boot, bootWhenReady } from './boot';
12
13
  import { bootWhenReady } from './boot';
@@ -1,4 +1,11 @@
1
1
  export const BRIDGE_CONFIG = (typeof window !== 'undefined' && window.__LX_BRIDGE_CFG) || {};
2
+ const displayLanguage = BRIDGE_CONFIG.displayLanguage?.trim() || 'en-US';
3
+ if (typeof document !== 'undefined' && document.documentElement) {
4
+ document.documentElement.lang = displayLanguage;
5
+ }
6
+ export function getDisplayLanguage() {
7
+ return displayLanguage;
8
+ }
2
9
  export function getPlatformOS() {
3
10
  return BRIDGE_CONFIG.os || 'unknown';
4
11
  }
@@ -14,14 +21,37 @@ export function isAndroid() {
14
21
  export function isMacOS() {
15
22
  return BRIDGE_CONFIG.os === 'macOS';
16
23
  }
24
+ export function isWindows() {
25
+ return BRIDGE_CONFIG.os === 'Windows';
26
+ }
17
27
  export function isDesktop() {
18
- return isMacOS();
28
+ return isMacOS() || isWindows();
29
+ }
30
+ // iOS and macOS share the WKWebView transport, so features scoped to it (e.g.
31
+ // the streaming downstream) key off this rather than the two OS checks.
32
+ export function isApple() {
33
+ return isIOS() || isMacOS();
34
+ }
35
+ // True when attached to a `lingxia dev` session (the host sets `dev` in
36
+ // `__LX_BRIDGE_CFG`). Used to surface the bridge's own protocol/lifecycle trace
37
+ // only during development.
38
+ export function isDevSession() {
39
+ return BRIDGE_CONFIG.dev === true;
40
+ }
41
+ // True when running inside the LingXia Runner (the `lingxia dev` device
42
+ // simulator), which the host marks in `__LX_BRIDGE_CFG`. Unlike a real host
43
+ // app in dev mode, the Runner lacks host-declared surfaces such as the
44
+ // terminal — apps read this to hide those affordances.
45
+ export function isRunner() {
46
+ return BRIDGE_CONFIG.runner === true;
19
47
  }
20
48
  export function getCommunicationMethod() {
21
49
  if (BRIDGE_CONFIG.os === 'iOS' || BRIDGE_CONFIG.os === 'macOS')
22
50
  return 'webkit';
23
51
  if (BRIDGE_CONFIG.os === 'Harmony')
24
52
  return 'messageport';
53
+ if (BRIDGE_CONFIG.os === 'Windows')
54
+ return 'webmessage';
25
55
  if (BRIDGE_CONFIG.os === 'Android') {
26
56
  if (window.LingXiaProxy?.supportsMessagePort?.())
27
57
  return 'messageport';
@@ -1,9 +1,9 @@
1
- import { getPlatformOS } from '../runtime-env';
1
+ import { isDesktop } from '../runtime-env';
2
2
  const ANIMATION_MS = 300;
3
3
  const STYLE_ID = 'lx-action-sheet-style';
4
4
  let activeSheet = null;
5
5
  function isDesktopMode() {
6
- return getPlatformOS() === 'macOS';
6
+ return isDesktop();
7
7
  }
8
8
  function ensureActionSheetStyle() {
9
9
  if (document.getElementById(STYLE_ID))