@lingxia/bridge 0.14.0 → 0.16.0

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.
@@ -0,0 +1,191 @@
1
+ /** Document-side framing for host-attested RequiredV3 documents. */
2
+ export const V3_PROTOCOL = 3;
3
+ export const DEFAULT_MAX_V3_FRAME_BYTES = 64 * 1024;
4
+ const documentToNativeKinds = new Set([
5
+ "hello",
6
+ "req",
7
+ "res",
8
+ "notify",
9
+ "cancel",
10
+ "ch.open",
11
+ "ch.data",
12
+ "ch.close",
13
+ "state.ack",
14
+ "console",
15
+ ]);
16
+ const nativeToDocumentKinds = new Set([
17
+ "helloAck",
18
+ "ready",
19
+ "req",
20
+ "res",
21
+ "event",
22
+ "state.snapshot",
23
+ "state.patch",
24
+ "ch.ack",
25
+ "ch.data",
26
+ "ch.close",
27
+ ]);
28
+ function isRecord(value) {
29
+ return value !== null && typeof value === "object" && !Array.isArray(value);
30
+ }
31
+ function hasDuplicateTopLevelSecurityKey(frame) {
32
+ const securityKeys = new Set(["v", "kind", "sessionId", "secret"]);
33
+ const seen = new Set();
34
+ let depth = 0;
35
+ for (let index = 0; index < frame.length; index++) {
36
+ const char = frame[index];
37
+ if (char === "{" || char === "[") {
38
+ depth++;
39
+ continue;
40
+ }
41
+ if (char === "}" || char === "]") {
42
+ depth--;
43
+ continue;
44
+ }
45
+ if (char !== '"')
46
+ continue;
47
+ const start = index;
48
+ index++;
49
+ while (index < frame.length) {
50
+ if (frame[index] === "\\") {
51
+ index += 2;
52
+ continue;
53
+ }
54
+ if (frame[index] === '"')
55
+ break;
56
+ index++;
57
+ }
58
+ if (index >= frame.length)
59
+ return false;
60
+ if (depth !== 1)
61
+ continue;
62
+ let next = index + 1;
63
+ while (/\s/.test(frame[next] ?? ""))
64
+ next++;
65
+ if (frame[next] !== ":")
66
+ continue;
67
+ let key;
68
+ try {
69
+ key = JSON.parse(frame.slice(start, index + 1));
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ if (typeof key === "string" && securityKeys.has(key)) {
75
+ if (seen.has(key))
76
+ return true;
77
+ seen.add(key);
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+ /**
83
+ * Captures the document secret in a closure. Neither the returned codec nor
84
+ * parsed native messages expose it.
85
+ */
86
+ export function createV3DocumentCodec(binding) {
87
+ if (!isRecord(binding) ||
88
+ typeof binding.sessionId !== "string" ||
89
+ binding.sessionId.length === 0 ||
90
+ typeof binding.secret !== "string" ||
91
+ binding.secret.length === 0) {
92
+ return { ok: false, error: "INVALID_DOCUMENT_BINDING" };
93
+ }
94
+ const { sessionId, secret } = binding;
95
+ return {
96
+ ok: true,
97
+ value: {
98
+ encode(kind, payload) {
99
+ if (!documentToNativeKinds.has(kind) || !isRecord(payload)) {
100
+ return { ok: false, error: "INVALID_DOCUMENT_PAYLOAD" };
101
+ }
102
+ if (["v", "kind", "sessionId", "secret"].some((field) => field in payload)) {
103
+ return { ok: false, error: "SECURITY_FIELD_IN_PAYLOAD" };
104
+ }
105
+ return {
106
+ ok: true,
107
+ value: { ...payload, v: V3_PROTOCOL, kind, sessionId, secret },
108
+ };
109
+ },
110
+ parse(frame, maxFrameBytes = DEFAULT_MAX_V3_FRAME_BYTES) {
111
+ // Envelope checks deliberately precede typed payload handling.
112
+ if (new TextEncoder().encode(frame).byteLength > maxFrameBytes) {
113
+ return { ok: false, error: "FRAME_TOO_LARGE" };
114
+ }
115
+ if (hasDuplicateTopLevelSecurityKey(frame)) {
116
+ return { ok: false, error: "MALFORMED_ENVELOPE" };
117
+ }
118
+ let candidate;
119
+ try {
120
+ candidate = JSON.parse(frame);
121
+ }
122
+ catch {
123
+ return { ok: false, error: "MALFORMED_ENVELOPE" };
124
+ }
125
+ if (!isRecord(candidate))
126
+ return { ok: false, error: "MALFORMED_ENVELOPE" };
127
+ if (candidate.v !== V3_PROTOCOL) {
128
+ return { ok: false, error: "UNSUPPORTED_VERSION" };
129
+ }
130
+ if (typeof candidate.kind !== "string" ||
131
+ !nativeToDocumentKinds.has(candidate.kind)) {
132
+ return { ok: false, error: "UNSUPPORTED_NATIVE_KIND" };
133
+ }
134
+ if (typeof candidate.sessionId !== "string" || candidate.sessionId.length === 0) {
135
+ return { ok: false, error: "MALFORMED_ENVELOPE" };
136
+ }
137
+ if (candidate.sessionId !== sessionId)
138
+ return { ok: false, error: "SESSION_MISMATCH" };
139
+ if (Object.prototype.hasOwnProperty.call(candidate, "secret")) {
140
+ return { ok: false, error: "UNEXPECTED_SECRET" };
141
+ }
142
+ const { v: _v, kind: _kind, sessionId: _sessionId, ...payload } = candidate;
143
+ return {
144
+ ok: true,
145
+ value: { kind: candidate.kind, payload },
146
+ };
147
+ },
148
+ },
149
+ };
150
+ }
151
+ /**
152
+ * Atomically takes the host-installed one-shot handoff. The secret remains in
153
+ * the returned codec closure and is never copied into bridge globals/config.
154
+ */
155
+ export function consumeV3Bootstrap() {
156
+ if (typeof window === "undefined")
157
+ return { kind: "absent" };
158
+ const take = window.__LingXiaTakeControlBootstrap;
159
+ if (typeof take !== "function")
160
+ return { kind: "absent" };
161
+ try {
162
+ const bootstrap = take();
163
+ if (!bootstrap ||
164
+ bootstrap.requiredProtocol !== V3_PROTOCOL ||
165
+ typeof bootstrap.publicSessionId !== "string" ||
166
+ bootstrap.publicSessionId.length === 0 ||
167
+ typeof bootstrap.secret !== "string" ||
168
+ bootstrap.secret.length === 0) {
169
+ return { kind: "blocked" };
170
+ }
171
+ const codec = createV3DocumentCodec({
172
+ sessionId: bootstrap.publicSessionId,
173
+ secret: bootstrap.secret,
174
+ });
175
+ return codec.ok
176
+ ? { kind: "required", codec: codec.value }
177
+ : { kind: "blocked" };
178
+ }
179
+ catch {
180
+ return { kind: "blocked" };
181
+ }
182
+ finally {
183
+ // A malformed replacement must not leave a callable handoff for later.
184
+ try {
185
+ delete window.__LingXiaTakeControlBootstrap;
186
+ }
187
+ catch {
188
+ // A hostile non-configurable property has no attested secret to retain.
189
+ }
190
+ }
191
+ }
@@ -42,7 +42,7 @@ function applyDisplayLanguage(next) {
42
42
  current.value = normalized;
43
43
  stampDocumentLanguage();
44
44
  for (const listener of [...current.listeners])
45
- listener();
45
+ listener(normalized);
46
46
  }
47
47
  if (typeof window !== 'undefined' && !window.__lingxiaApplyDisplayLanguage) {
48
48
  Object.defineProperty(window, '__lingxiaApplyDisplayLanguage', {
@@ -54,7 +54,14 @@ if (typeof window !== 'undefined' && !window.__lingxiaApplyDisplayLanguage) {
54
54
  export function getDisplayLanguage() {
55
55
  return store().value;
56
56
  }
57
- /** Subscribe to host display-language changes. Returns an unsubscribe. */
57
+ /**
58
+ * Subscribe to host display-language changes. Returns an unsubscribe.
59
+ *
60
+ * Change-only, so that this composes with `useSyncExternalStore`: the listener
61
+ * runs when the language changes, never on subscribe. Read the current value
62
+ * with `getDisplayLanguage()`. Logic's `lx.app.displayLanguage.watch` differs
63
+ * deliberately — it has no render loop to feed, so it delivers immediately.
64
+ */
58
65
  export function subscribeDisplayLanguage(listener) {
59
66
  const listeners = store().listeners;
60
67
  listeners.add(listener);
@@ -65,6 +72,23 @@ export function subscribeDisplayLanguage(listener) {
65
72
  export function getPlatformOS() {
66
73
  return BRIDGE_CONFIG.os || 'unknown';
67
74
  }
75
+ /**
76
+ * Which kind of machine this is, mobile or desktop. Read through `isMobile()`
77
+ * and `isDesktop()`; the class itself is host vocabulary, not lxapp API.
78
+ *
79
+ * Fixed for the life of the document. A shipped host is one machine; the
80
+ * Runner re-serves the page when its simulated device changes class, so this
81
+ * never has to change under a page that is already rendering.
82
+ *
83
+ * Hosts from before this config key shipped send nothing, so fall back to the
84
+ * OS: every one of them is the machine it names.
85
+ */
86
+ function hostClass() {
87
+ if (BRIDGE_CONFIG.hostClass === 'mobile' || BRIDGE_CONFIG.hostClass === 'desktop') {
88
+ return BRIDGE_CONFIG.hostClass;
89
+ }
90
+ return BRIDGE_CONFIG.os === 'macOS' || BRIDGE_CONFIG.os === 'Windows' ? 'desktop' : 'mobile';
91
+ }
68
92
  export function isHarmony() {
69
93
  return BRIDGE_CONFIG.os === 'Harmony';
70
94
  }
@@ -80,8 +104,13 @@ export function isMacOS() {
80
104
  export function isWindows() {
81
105
  return BRIDGE_CONFIG.os === 'Windows';
82
106
  }
107
+ // Form factor, not OS: the Runner shows a real macOS/Windows build inside a
108
+ // phone frame, and a page that keyed off the OS would keep its desktop layout.
83
109
  export function isDesktop() {
84
- return isMacOS() || isWindows();
110
+ return hostClass() === 'desktop';
111
+ }
112
+ export function isMobile() {
113
+ return hostClass() === 'mobile';
85
114
  }
86
115
  // iOS and macOS share the WKWebView transport, so features scoped to it (e.g.
87
116
  // the streaming downstream) key off this rather than the two OS checks.
@@ -13,4 +13,5 @@ export const BRIDGE_ERROR = {
13
13
  OUTBOX_FULL: 'BRIDGE_OUTBOX_FULL',
14
14
  STREAM_OVERFLOW: 'BRIDGE_STREAM_OVERFLOW',
15
15
  STREAM_CLOSED: 'BRIDGE_STREAM_CLOSED',
16
+ MESSAGE_TOO_LARGE: 'BRIDGE_MESSAGE_TOO_LARGE',
16
17
  };