Skip to content
40 changes: 20 additions & 20 deletions packages/walletkit-android-bridge/src/api/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ type InternalStreamingManager = {
providerConnectionUnsubs: Map<string, () => void>;
};

function trackKotlinSub(providerId: string, subId: string): void {
function trackKotlinSub(providerId: string, subscriptionId: string): void {
let subs = kotlinProviderSubs.get(providerId);
if (!subs) {
subs = new Set<string>();
kotlinProviderSubs.set(providerId, subs);
}
subs.add(subId);
subs.add(subscriptionId);
}

function forgetKotlinSub(providerId: string, subId: string): void {
function forgetKotlinSub(providerId: string, subscriptionId: string): void {
const subs = kotlinProviderSubs.get(providerId);
if (!subs) return;
subs.delete(subId);
subs.delete(subscriptionId);
if (subs.size === 0) {
kotlinProviderSubs.delete(providerId);
}
Expand Down Expand Up @@ -85,14 +85,14 @@ class ProxyStreamingProvider implements StreamingProvider {
}

private watch(type: string, address: string | null, onChange: (update: unknown) => void): () => void {
const subId = uuidv7();
kotlinSubCallbacks.set(subId, onChange);
trackKotlinSub(this.providerId, subId);
void bridgeRequest('kotlinProviderWatch', { providerId: this.providerId, subId, type, address });
const subscriptionId = uuidv7();
kotlinSubCallbacks.set(subscriptionId, onChange);
trackKotlinSub(this.providerId, subscriptionId);
void bridgeRequest('kotlinProviderWatch', { providerId: this.providerId, subscriptionId, type, address });
return () => {
kotlinSubCallbacks.delete(subId);
forgetKotlinSub(this.providerId, subId);
void bridgeRequest('kotlinProviderUnwatch', { subId });
kotlinSubCallbacks.delete(subscriptionId);
forgetKotlinSub(this.providerId, subscriptionId);
void bridgeRequest('kotlinProviderUnwatch', { subscriptionId });
};
}

Expand Down Expand Up @@ -123,23 +123,23 @@ class ProxyStreamingProvider implements StreamingProvider {
dispose(): void {
const subs = kotlinProviderSubs.get(this.providerId);
if (!subs) return;
for (const subId of subs) {
kotlinSubCallbacks.delete(subId);
void bridgeRequest('kotlinProviderUnwatch', { subId });
for (const subscriptionId of subs) {
kotlinSubCallbacks.delete(subscriptionId);
void bridgeRequest('kotlinProviderUnwatch', { subscriptionId });
}
kotlinProviderSubs.delete(this.providerId);
}
}

export async function createTonCenterStreamingProvider(args: { config: TonCenterStreamingProviderConfig }) {
export async function createTonCenterStreamingProvider(config: TonCenterStreamingProviderConfig) {
const instance = await getKit();
const provider = new TonCenterStreamingProvider(instance.createFactoryContext(), args.config);
const provider = new TonCenterStreamingProvider(instance.createFactoryContext(), config);
return { providerId: retain('streamingProvider', provider) };
}

export async function createTonApiStreamingProvider(args: { config: TonApiStreamingProviderConfig }) {
export async function createTonApiStreamingProvider(config: TonApiStreamingProviderConfig) {
const instance = await getKit();
const provider = new TonApiStreamingProvider(instance.createFactoryContext(), args.config);
const provider = new TonApiStreamingProvider(instance.createFactoryContext(), config);
return { providerId: retain('streamingProvider', provider) };
}

Expand Down Expand Up @@ -240,8 +240,8 @@ export async function registerKotlinStreamingProvider(args: { providerId: string
instance.streaming.registerProvider(() => provider);
}

export async function kotlinProviderDispatch(args: { subId: string; updateJson: string }) {
const callback = kotlinSubCallbacks.get(args.subId);
export async function kotlinProviderDispatch(args: { subscriptionId: string; updateJson: string }) {
const callback = kotlinSubCallbacks.get(args.subscriptionId);
if (callback) {
try {
callback(JSON.parse(args.updateJson));
Expand Down
51 changes: 46 additions & 5 deletions packages/walletkit-android-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,60 @@
*
*/

import type { WalletKitBridgeApi } from './types';
import type { WalletKitApiMethod, WalletKitBridgeApi } from './types';
import { api } from './api';
import { setBridgeApi, registerNativeCallHandler } from './transport/messaging';
import { registerNativeResponseHandler } from './transport/nativeBridge';
import { handleNativeCall, setBridgeApi } from './transport/messaging';
import { handleNativeResponse } from './transport/nativeBridge';
import { installPortHandshake, setInboundCallback } from './transport/port';
import { warn, error } from './utils/logger';

declare global {
interface Window {
walletkitBridge?: WalletKitBridgeApi;
}
}

interface IncomingCallEnvelope {
kind: 'call';
id: string;
method: WalletKitApiMethod;
params?: unknown;
}

interface IncomingResponseEnvelope {
kind: 'response';
id: string;
result?: unknown;
error?: { message?: string };
}

type IncomingEnvelope = IncomingCallEnvelope | IncomingResponseEnvelope;

setBridgeApi(api as unknown as WalletKitBridgeApi);
registerNativeCallHandler();
registerNativeResponseHandler();

// Synchronous: must be in place before native onPageFinished posts the port.
installPortHandshake();

setInboundCallback((json) => {
let envelope: IncomingEnvelope;
try {
envelope = JSON.parse(json) as IncomingEnvelope;
} catch (err) {
error('[walletkitBridge] Failed to parse inbound port message', err, json);
return;
}
switch (envelope.kind) {
case 'call':
handleNativeCall(envelope.id, envelope.method, envelope.params);
break;
case 'response':
handleNativeResponse(envelope.id, envelope.result, envelope.error);
break;
default: {
const exhaustive: never = envelope;
warn('[walletkitBridge] Unknown inbound envelope kind', exhaustive);
}
}
});

window.walletkitBridge = api as unknown as WalletKitBridgeApi;
8 changes: 5 additions & 3 deletions packages/walletkit-android-bridge/src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
*/

// Re-export bridge types for backwards compatibility
import type { AndroidBridgeType, WalletKitNativeBridgeType, WalletKitBridgeApi, WalletKitApiMethod } from './types';
import type { AndroidBridgeType, WalletKitNativeBridgeType, WalletKitBridgeApi } from './types';

declare global {
interface Window {
walletkitBridge?: WalletKitBridgeApi;
__walletkitCall?: (id: string, method: WalletKitApiMethod, paramsJson?: string | null) => void;
__walletkitResponse?: (id: string, resultJson?: string | null, errorJson?: string | null) => void;
// WalletKitNative still hosts the synchronous host calls (storageGet/Set, sessionCreate,
// apiSendBoc/RunGetMethod/GetBalance, …). The bidirectional bridge messaging that used
// to live on `__walletkitCall` / `__walletkitResponse` now flows through a
// WebMessagePort handed off from Kotlin during page load.
WalletKitNative?: WalletKitNativeBridgeType;
AndroidBridge?: AndroidBridgeType;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/walletkit-android-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
/**
* Entry point for Android WalletKit bridge.
* This file ensures native polyfills are installed before the bridge code executes.
* The bridge bundle does not export anything - all communication happens via window.__walletkitCall.
* The bridge bundle does not export anything — all communication happens through the
* WebMessagePort handed off from the native side (see `transport/port.ts`).
*/
import './polyfills/setupNativeBridge';
import './bridge';
15 changes: 2 additions & 13 deletions packages/walletkit-android-bridge/src/transport/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,6 @@ export async function handleCall(id: string, method: WalletKitApiMethod, params?
}
}

export function registerNativeCallHandler(): void {
window.__walletkitCall = (id, method, paramsJson) => {
let params: unknown = undefined;
if (paramsJson && paramsJson !== 'null') {
try {
params = JSON.parse(paramsJson);
} catch {
respond(id, undefined, { message: 'Invalid params JSON' });
return;
}
}
void handleCall(id, method, params);
};
export function handleNativeCall(id: string, method: WalletKitApiMethod, params: unknown): void {
void handleCall(id, method, params);
}
112 changes: 24 additions & 88 deletions packages/walletkit-android-bridge/src/transport/nativeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,12 @@ import { v7 as uuidv7 } from 'uuid';

import type { BridgePayload } from '../types';
import { bigIntReplacer } from '../utils/serialization';
import { warn, error, info } from '../utils/logger';

// Reverse-RPC: JS sends {kind:'request', id, method, params} via postMessage.
// Kotlin responds via window.__walletkitResponse(id, resultJson, errorJson).
import { warn, error } from '../utils/logger';
import { sendToNative } from './port';

const pendingRequests = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();

/**
* Synchronous bridge call via @JavascriptInterface (WalletKitNative.adapterCallSync).
* Used for sync WalletAdapter getters that cannot be async.
*/
// Sync host call via @JavascriptInterface — WebMessagePort is async and can't satisfy sync getters.
export function bridgeRequestSync(method: string, params: Record<string, unknown>): string {
const native = window.WalletKitNative;
if (!native || typeof native.adapterCallSync !== 'function') {
Expand All @@ -29,9 +24,6 @@ export function bridgeRequestSync(method: string, params: Record<string, unknown
return native.adapterCallSync(method, JSON.stringify(params));
}

/**
* Send a request to Kotlin via postMessage and wait for a response.
*/
export function bridgeRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
const id = uuidv7();
return new Promise<unknown>((resolve, reject) => {
Expand All @@ -40,76 +32,33 @@ export function bridgeRequest(method: string, params: Record<string, unknown>):
});
}

export function registerNativeResponseHandler(): void {
window.__walletkitResponse = (id: string, resultJson?: string | null, errorJson?: string | null) => {
const entry = pendingRequests.get(id);
if (!entry) {
warn('[walletkitBridge] __walletkitResponse: no pending request for id', id);
return;
}
pendingRequests.delete(id);

if (errorJson) {
try {
const err = JSON.parse(errorJson);
entry.reject(new Error(err.message ?? 'Native request failed'));
} catch {
entry.reject(new Error(errorJson));
}
return;
}

if (resultJson) {
try {
entry.resolve(JSON.parse(resultJson));
} catch {
// If it's not JSON, return the raw string
entry.resolve(resultJson);
}
} else {
entry.resolve(undefined);
}
};
info('[walletkitBridge] __walletkitResponse handler registered');
}

/**
* Resolves WalletKit's native bridge implementation exposed on the global scope.
*/
export function resolveNativeBridge(scope: typeof globalThis) {
const candidate = (scope as typeof globalThis & { WalletKitNative?: { postMessage?: (json: string) => void } })
.WalletKitNative;
if (candidate && typeof candidate.postMessage === 'function') {
return candidate.postMessage.bind(candidate);
export function handleNativeResponse(id: string, resultJson: unknown, errorJson: unknown): void {
const entry = pendingRequests.get(id);
if (!entry) {
warn('[walletkitBridge] handleNativeResponse: no pending request for id', id);
return;
}
const windowRef = typeof scope.window === 'object' && scope.window ? scope.window : undefined;
const windowCandidate = windowRef?.WalletKitNative;
if (windowCandidate && typeof windowCandidate.postMessage === 'function') {
return windowCandidate.postMessage.bind(windowCandidate);
pendingRequests.delete(id);

if (errorJson) {
const err = errorJson as { message?: string };
entry.reject(new Error(err.message ?? 'Native request failed'));
return;
}
return null;
}

/**
* Resolves the Android bridge exposed by the host WebView.
*/
export function resolveAndroidBridge(scope: typeof globalThis) {
const candidate = (scope as typeof globalThis & { AndroidBridge?: { postMessage?: (json: string) => void } })
.AndroidBridge;
if (candidate && typeof candidate.postMessage === 'function') {
return candidate.postMessage.bind(candidate);
if (resultJson === null || resultJson === undefined) {
entry.resolve(undefined);
return;
}
const windowRef = typeof scope.window === 'object' && scope.window ? scope.window : undefined;
const windowCandidate = windowRef?.AndroidBridge;
if (windowCandidate && typeof windowCandidate.postMessage === 'function') {
return windowCandidate.postMessage.bind(windowCandidate);

if (typeof resultJson === 'string') {
entry.resolve(JSON.parse(resultJson));
return;
}
return null;

entry.resolve(resultJson);
}

/**
* Sends a payload to the native bridge, falling back to debug logging when unavailable.
*/
export function postToNative(payload: BridgePayload): void {
if (payload === null || (typeof payload !== 'object' && typeof payload !== 'function')) {
const diagnostic = {
Expand All @@ -121,18 +70,5 @@ export function postToNative(payload: BridgePayload): void {
throw new Error('Invalid payload - must be an object');
}
const json = JSON.stringify(payload, bigIntReplacer);
const nativePostMessage = resolveNativeBridge(window);
if (nativePostMessage) {
nativePostMessage(json);
return;
}
const androidPostMessage = resolveAndroidBridge(window);
if (androidPostMessage) {
androidPostMessage(json);
return;
}
if (payload.kind === 'event') {
throw new Error('Native bridge not available - cannot deliver event');
}
warn('[walletkitBridge] postToNative: no native handler', payload);
sendToNative(json);
}
Loading
Loading