Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions ci/bridge_rpc_names.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,15 @@
# Handled names are the string arms of the `when (method)` inside
# SinglePathWebViewBridge.handleBinaryRpcInternal.
#
# Exit 0 only when both sets are non-empty and equal. There is no allowlist: a
# name one side must stop using is removed from that side.
# The bridge object itself is held to the same rule: the members of the
# frontend's `AndroidBridgeV3` must be exactly the keys `index.html` installs on
# `window.DsmBridge` (every production call on the bridge object is typed
# against that interface), and no production source may name `__callBin`, the
# transport function only the jest stub installed and every transport path
# once branched on.
#
# Exit 0 only when every set is non-empty and each pair is equal. There is no
# allowlist: a name one side must stop using is removed from that side.

import os
import re
Expand All @@ -28,6 +35,7 @@
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
FRONTEND_SRC = os.path.join(ROOT, "dsm_client", "frontend", "src")
INDEX_HTML = os.path.join(ROOT, "dsm_client", "frontend", "public", "index.html")
BRIDGE_TYPES = os.path.join(ROOT, "dsm_client", "frontend", "src", "dsm", "bridgeTypes.ts")
KOTLIN_BRIDGE = os.path.join(
ROOT, "dsm_client", "android", "app", "src", "main", "java", "com", "dsm", "wallet",
"bridge", "SinglePathWebViewBridge.kt",
Expand All @@ -39,12 +47,20 @@
CALL_IDENT_RE = re.compile(r"\bcallBin\(\s*([A-Z][A-Z0-9_]+)\b")
HTML_RE = re.compile(r"\b(?:callBridgeMethod|encodeBridgeRequest)\(\s*[\"']([A-Za-z0-9_]+)[\"']")
ARM_RE = re.compile(r"^\s*\"([A-Za-z0-9_]+)\"\s*->", re.M)
# A member of the object literal: `key: value` or the shorthand `key,`.
INSTALLED_KEY_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::|,\s*$)", re.M)
TYPE_MEMBER_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\??\s*:", re.M)


def fail(msg):
print(f"[bridge-rpc-names] FAIL: {msg}")


def read_text(path):
with open(path, encoding="utf-8") as f:
return f.read()


def frontend_sources():
for dirpath, dirnames, filenames in os.walk(FRONTEND_SRC):
if "__tests__" in dirpath or os.sep + "proto" in dirpath[len(FRONTEND_SRC):]:
Expand All @@ -62,7 +78,7 @@ def sent_names():
sent = {}
unresolved = []
for path in frontend_sources():
text = open(path, encoding="utf-8").read()
text = read_text(path)
rel = os.path.relpath(path, ROOT)
for m in CALL_RE.finditer(text):
sent.setdefault(m.group(1), []).append(f"{rel}:{text.count(chr(10), 0, m.start()) + 1}")
Expand All @@ -73,7 +89,7 @@ def sent_names():
sent.setdefault(const.group(1), []).append(where)
else:
unresolved.append(f"{where} ({m.group(1)})")
html = open(INDEX_HTML, encoding="utf-8").read()
html = read_text(INDEX_HTML)
for m in HTML_RE.finditer(html):
sent.setdefault(m.group(1), []).append(
f"{os.path.relpath(INDEX_HTML, ROOT)}:{html.count(chr(10), 0, m.start()) + 1}"
Expand All @@ -87,7 +103,7 @@ def sent_names():


def handled_names():
text = open(KOTLIN_BRIDGE, encoding="utf-8").read()
text = read_text(KOTLIN_BRIDGE)
fn = text.find("fun handleBinaryRpcInternal(")
if fn < 0:
fail(f"{os.path.relpath(KOTLIN_BRIDGE, ROOT)}: handleBinaryRpcInternal not found")
Expand Down Expand Up @@ -117,9 +133,54 @@ def handled_names():
return handled


def installed_bridge_keys():
"""The keys of the object literal `index.html` assigns to `window.DsmBridge`."""
html = read_text(INDEX_HTML)
start = html.find("window.DsmBridge = {")
if start < 0:
fail("index.html does not install `window.DsmBridge = {`")
sys.exit(2)
i = html.index("{", start)
depth = 0
j = i
while j < len(html):
if html[j] == "{":
depth += 1
elif html[j] == "}":
depth -= 1
if depth == 0:
break
j += 1
return set(INSTALLED_KEY_RE.findall(html[i + 1:j]))


def bridge_type_members():
text = read_text(BRIDGE_TYPES)
start = text.find("export interface AndroidBridgeV3 {")
if start < 0:
fail("bridgeTypes.ts does not declare AndroidBridgeV3")
sys.exit(2)
body = text[text.index("{", start) + 1:text.index("\n}", start)]
body = re.sub(r"/\*.*?\*/", "", body, flags=re.S)
body = re.sub(r"//[^\n]*", "", body)
return set(TYPE_MEMBER_RE.findall(body))


def production_names_callbin():
hits = []
for path in frontend_sources():
text = read_text(path)
for m in re.finditer(r"__callBin", text):
hits.append(f"{os.path.relpath(path, ROOT)}:{text.count(chr(10), 0, m.start()) + 1}")
return hits


def main():
sent = sent_names()
handled = handled_names()
installed = installed_bridge_keys()
typed = bridge_type_members()
callbin = production_names_callbin()
if not sent or not handled:
fail(f"a scan that finds nothing is not a scan (sent={len(sent)}, handled={len(handled)})")
return 2
Expand All @@ -136,8 +197,26 @@ def main():
for name in unsent:
print(f" {name}: " + ", ".join(handled[name]))
status = 1
if not installed or not typed:
fail(f"a scan that finds nothing is not a scan (installed={len(installed)}, typed={len(typed)})")
return 2
if installed != typed:
fail("the frontend's AndroidBridgeV3 and the bridge object index.html installs differ:")
for name in sorted(typed - installed):
print(f" typed but not installed: {name}")
for name in sorted(installed - typed):
print(f" installed but not typed: {name}")
status = 1
if callbin:
fail("production sources name `__callBin`, a transport only the jest stub installs:")
for where in callbin:
print(f" {where}")
status = 1
if status == 0:
print(f"[bridge-rpc-names] OK: {len(sent)} names sent, {len(handled)} handled, the same set")
print(
f"[bridge-rpc-names] OK: {len(sent)} names sent, {len(handled)} handled, the same set; "
f"the bridge object's {len(installed)} members typed as installed"
)
return status


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe('ContactsTabScreen BLE pairing', () => {

(globalThis as any).window = (globalThis as any).window || {};
(globalThis as any).window.DsmBridge = {
__callBin: async () => new Uint8Array(0),
sendMessageBin: async () => new Uint8Array(0),
};
(globalThis as any).requestAnimationFrame = () => 0;

Expand Down
5 changes: 2 additions & 3 deletions dsm_client/frontend/src/dsm/BridgeGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ export class BridgeGate {
* (`window.DsmBridge`, bytes-only). Safe to call repeatedly.
*/
refreshPrereqsOnce(): BridgePrereqState {
const b = (globalThis as { window?: { DsmBridge?: { __binary?: boolean; __callBin?: unknown } } })
.window?.DsmBridge;
const installed = !!(b && (b.__binary === true || typeof b.__callBin === 'function'));
const b = (globalThis as { window?: { DsmBridge?: { __binary?: boolean } } }).window?.DsmBridge;
const installed = b?.__binary === true;
if (installed && !this.prereq.bridgeReady) {
this.onEvent({ type: 'bridge.ready' });
}
Expand Down
80 changes: 14 additions & 66 deletions dsm_client/frontend/src/dsm/NativeBoundaryBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,7 @@
import { getBridgeInstance } from '../bridge/BridgeRegistry';
import { bridgeEvents } from '../bridge/bridgeEvents';
import type { AndroidBridgeV3 } from './bridgeTypes';
import { encodeBase32Crockford } from '../utils/textId';
import {
BridgeRpcRequest,
BridgeRpcResponse,
BytesPayload,
EmptyPayload,
EnvelopeOp,
IngressRequest,
IngressResponse,
RouterInvokeOp,
RouterQueryOp,
StartupRequest,
StartupResponse,
} from '../proto/dsm_app_pb';
import { EnvelopeOp, IngressRequest, IngressResponse, RouterInvokeOp, RouterQueryOp, StartupRequest, StartupResponse } from '../proto/dsm_app_pb';

function mustBridge(): AndroidBridgeV3 {
const bridge = getBridgeInstance();
Expand All @@ -34,62 +21,23 @@ function normalizeToBytes(data: unknown): Uint8Array {
throw new Error('expected Uint8Array response from native boundary');
}

function buildBridgeRequest(method: string, payload: Uint8Array): Uint8Array {
const req = new BridgeRpcRequest({
method,
payload:
payload.length > 0
? { case: 'bytes', value: new BytesPayload({ data: new Uint8Array(payload) }) }
: { case: 'empty', value: new EmptyPayload({}) },
});
return req.toBinary();
}

function unwrapBridgeRpcResponse(method: string, responseBytes: Uint8Array): Uint8Array {
let response: BridgeRpcResponse;
try {
response = BridgeRpcResponse.fromBinary(responseBytes);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`Bridge error: failed to decode response for ${method}: ${msg}`);
}
if (response.result.case === 'success') {
const data = response.result.value?.data;
return data instanceof Uint8Array ? data : new Uint8Array(0);
}
if (response.result.case === 'error') {
const errVal = response.result.value;
const message = errVal?.message || `bridge error while calling ${method}`;
const debugBytes = errVal ? errVal.toBinary() : new Uint8Array(0);
bridgeEvents.emit('bridge.error', {
code: errVal?.errorCode,
message,
debugB32: encodeBase32Crockford(debugBytes),
});
throw new Error(message);
}
throw new Error(`empty bridge response for ${method}`);
}

async function callBoundaryMethod(method: 'nativeBoundaryStartup' | 'nativeBoundaryIngress', payload: Uint8Array): Promise<Uint8Array> {
// `startup` and `ingress` are the bridge object's own wrappers over the
// MessagePort (`index.html`); they answer the boundary's bytes or throw.
const bridge = mustBridge();
if (method === 'nativeBoundaryStartup' && typeof bridge.startup === 'function') {
return normalizeToBytes(await bridge.startup(payload));
}
if (method === 'nativeBoundaryIngress' && typeof bridge.ingress === 'function') {
return normalizeToBytes(await bridge.ingress(payload));
const call = method === 'nativeBoundaryStartup' ? bridge.startup : bridge.ingress;
if (typeof call !== 'function') {
throw new Error(`DSM bridge does not expose ${method}`);
}

const requestBytes = buildBridgeRequest(method, payload);
if (typeof bridge.__callBin === 'function') {
const responseBytes = await bridge.__callBin(requestBytes);
return unwrapBridgeRpcResponse(method, normalizeToBytes(responseBytes));
}
if (bridge.__binary === true && typeof bridge.sendMessageBin === 'function') {
const responseBytes = await bridge.sendMessageBin(requestBytes);
return unwrapBridgeRpcResponse(method, normalizeToBytes(responseBytes));
try {
return normalizeToBytes(await call(payload));
} catch (e) {
// The wrapper reduces Kotlin's ErrorResponse to its message; that message
// reaches the diagnostics bus as the RPC path's failures do.
const message = e instanceof Error ? e.message : String(e);
bridgeEvents.emit('bridge.error', { code: 0, message, debugB32: '' });
throw e;
}
throw new Error('DSM bridge does not expose the native boundary transport');
}

function encodeStartupRequest(request: StartupRequest | Uint8Array): Uint8Array {
Expand Down
75 changes: 13 additions & 62 deletions dsm_client/frontend/src/dsm/NativeHostBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,11 @@
// SPDX-License-Identifier: Apache-2.0

import { getBridgeInstance } from '../bridge/BridgeRegistry';
import { bridgeEvents } from '../bridge/bridgeEvents';
import logger from '../utils/logger';
import type { AndroidBridgeV3 } from './bridgeTypes';
import { bridgeGate } from './BridgeGate';
import {
BiometricAuthorizePayload,
BiometricAuthorizeResult,
BridgeRpcRequest,
BridgeRpcResponse,
BytesPayload,
EmptyPayload,
HostPermissionsRequestPayload,
NativeHostAck,
NativeHostCapabilities,
NativeHostEvent,
NativeHostEventKind,
NativeHostRequest,
NativeHostRequestKind,
NativeHostResponse,
NfcTagReadPayload,
NfcTagReadResult,
NfcTagWritePayload,
NfcTagWriteResult,
QrScanResultPayload,
} from '../proto/dsm_app_pb';
import { BiometricAuthorizePayload, BiometricAuthorizeResult, HostPermissionsRequestPayload, NativeHostAck, NativeHostCapabilities, NativeHostEvent, NativeHostEventKind, NativeHostRequest, NativeHostRequestKind, NativeHostResponse, NfcTagReadPayload, NfcTagReadResult, NfcTagWritePayload, NfcTagWriteResult, QrScanResultPayload } from '../proto/dsm_app_pb';

function mustBridge(): AndroidBridgeV3 {
const bridge = getBridgeInstance();
Expand All @@ -42,46 +23,20 @@ function normalizeToBytes(data: unknown): Uint8Array {
throw new Error('expected Uint8Array response from native host boundary');
}

function buildBridgeRequest(method: string, payload: Uint8Array): Uint8Array {
const req = new BridgeRpcRequest({
method,
payload:
payload.length > 0
? { case: 'bytes', value: new BytesPayload({ data: new Uint8Array(payload) }) }
: { case: 'empty', value: new EmptyPayload({}) },
});
return req.toBinary();
}

function unwrapBridgeRpcResponse(method: string, responseBytes: Uint8Array): Uint8Array {
const response = BridgeRpcResponse.fromBinary(responseBytes);
if (response.result.case === 'success') {
const data = response.result.value?.data;
return data instanceof Uint8Array ? data : new Uint8Array(0);
}
if (response.result.case === 'error') {
const message = response.result.value?.message || `bridge error while calling ${method}`;
throw new Error(message);
}
throw new Error(`empty bridge response for ${method}`);
}

async function callHostMethod(payload: Uint8Array): Promise<Uint8Array> {
// `hostRequest` is the bridge object's own wrapper over the MessagePort
// (`index.html`); it answers the host's bytes or throws.
const bridge = mustBridge();
if (typeof bridge.hostRequest === 'function') {
return normalizeToBytes(await bridge.hostRequest(payload));
}

const requestBytes = buildBridgeRequest('nativeHostRequest', payload);
if (typeof bridge.__callBin === 'function') {
const responseBytes = await bridge.__callBin(requestBytes);
return unwrapBridgeRpcResponse('nativeHostRequest', normalizeToBytes(responseBytes));
if (typeof bridge.hostRequest !== 'function') {
throw new Error('DSM bridge does not expose nativeHostRequest');
}
if (bridge.__binary === true && typeof bridge.sendMessageBin === 'function') {
const responseBytes = await bridge.sendMessageBin(requestBytes);
return unwrapBridgeRpcResponse('nativeHostRequest', normalizeToBytes(responseBytes));
try {
return normalizeToBytes(await bridge.hostRequest(payload));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
bridgeEvents.emit('bridge.error', { code: 0, message, debugB32: '' });
throw e;
}
throw new Error('DSM bridge does not expose the native host boundary transport');
}

function encodeRequest(request: NativeHostRequest | Uint8Array): Uint8Array {
Expand All @@ -104,11 +59,7 @@ function unwrapHostResponse(responseBytes: Uint8Array): Uint8Array {

export function isNativeHostUnavailableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return (
error.message.includes('Unknown binary RPC method: nativeHostRequest') ||
error.message.includes('unhandled __callBin method') ||
error.message.includes('does not expose the native host boundary transport')
);
return error.message.includes('Unknown binary RPC method: nativeHostRequest');
}

export async function hostRequest(request: NativeHostRequest | Uint8Array): Promise<Uint8Array> {
Expand Down
Loading
Loading