diff --git a/src/PeraWalletConnect.ts b/src/PeraWalletConnect.ts index 6a2540c..3cfb983 100644 --- a/src/PeraWalletConnect.ts +++ b/src/PeraWalletConnect.ts @@ -35,6 +35,7 @@ import {isMobile} from "./util/device/deviceUtils"; import {AlgorandChainIDs} from "./util/peraWalletTypes"; import {runWebSignTransactionFlow} from "./util/sign/signTransactionFlow"; import {runWebConnectFlow} from "./util/connect/connectFlow"; +import {getPublicSettings} from "./util/webview-api/webviewApi"; interface PeraWalletConnectOptions { bridge?: string; @@ -51,7 +52,8 @@ function generatePeraWalletConnectModalActions({ compactMode, promoteMobile, singleAccount, - selectedAccount + selectedAccount, + isInWebview }: PeraWalletModalConfig) { return { open: openPeraWalletConnectModal({ @@ -61,7 +63,8 @@ function generatePeraWalletConnectModalActions({ compactMode, promoteMobile, singleAccount, - selectedAccount + selectedAccount, + isInWebview }), close: () => removeModalWrapperFromDOM(PERA_WALLET_CONNECT_MODAL_ID) }; @@ -71,6 +74,7 @@ class PeraWalletConnect { bridge: string; connector: WalletConnect | null; shouldShowSignTxnToast: boolean; + isInWebview: boolean; chainId?: AlgorandChainIDs; compactMode?: boolean; singleAccount?: boolean; @@ -85,6 +89,7 @@ class PeraWalletConnect { : options.shouldShowSignTxnToast; this.chainId = options?.chainId; + this.isInWebview = false; this.compactMode = options?.compactMode || false; this.singleAccount = options?.singleAccount || false; } @@ -107,6 +112,20 @@ class PeraWalletConnect { return this.checkIsPeraDiscoverBrowser(); } + private async checkIsInWebview(): Promise { + if (isMobile()) { + try { + const publicSettings = await getPublicSettings(); + + return publicSettings !== null; + } catch { + return false; + } + } + + return false; + } + // `selectedAccount` option is only applicable for Pera Wallet products connect(options?: {selectedAccount?: string}) { return new Promise(async (resolve, reject) => { @@ -130,6 +149,8 @@ class PeraWalletConnect { promoteMobile } = await getPeraConnectConfig(); + this.isInWebview = await this.checkIsInWebview(); + const onWebWalletConnect = runWebConnectFlow({ resolve, reject, @@ -153,7 +174,8 @@ class PeraWalletConnect { compactMode: this.compactMode, promoteMobile, singleAccount: this.singleAccount, - selectedAccount: options?.selectedAccount + selectedAccount: options?.selectedAccount, + isInWebview: this.isInWebview }) }); @@ -230,6 +252,8 @@ class PeraWalletConnect { } // Pera Mobile Wallet flow + this.isInWebview = await this.checkIsInWebview(); + if (this.connector) { resolve(this.connector.accounts || []); } @@ -428,7 +452,7 @@ class PeraWalletConnect { signerAddress?: string ): Promise { if (this.platform === "mobile") { - if (isMobile()) { + if (isMobile() && !this.isInWebview) { // This is to automatically open the wallet app when trying to sign with it. openPeraWalletRedirectModal(); } else if (!isMobile() && this.shouldShowSignTxnToast) { @@ -457,7 +481,6 @@ class PeraWalletConnect { // Pera Mobile Wallet flow return this.signTransactionWithMobile(signTxnRequestParams); - // ================================================= // } async signData(data: PeraWalletArbitraryData[], signer: string): Promise { @@ -465,7 +488,7 @@ class PeraWalletConnect { const chainId = this.chainId || 4160; if (this.platform === "mobile") { - if (isMobile()) { + if (isMobile() && !this.isInWebview) { // This is to automatically open the wallet app when trying to sign with it. openPeraWalletRedirectModal(); } else if (!isMobile() && this.shouldShowSignTxnToast) { diff --git a/src/modal/PeraWalletConnectModal.ts b/src/modal/PeraWalletConnectModal.ts index dff586d..184143c 100644 --- a/src/modal/PeraWalletConnectModal.ts +++ b/src/modal/PeraWalletConnectModal.ts @@ -14,67 +14,77 @@ export class PeraWalletConnectModal extends HTMLElement { constructor() { super(); this.attachShadow({mode: "open"}); + } + connectedCallback() { if (this.shadowRoot) { - const styleSheet = document.createElement("style"); + this.render(); + } + } - styleSheet.textContent = styles; + private render() { + if (!this.shadowRoot) { + return; + } - const isCompactMode = this.getAttribute("compact-mode") === "true"; + const styleSheet = document.createElement("style"); - if (isCompactMode) { - peraWalletConnectModalClassNames = `${peraWalletConnectModalClassNames} ${PERA_WALLET_MODAL_CLASSNAME}--compact`; - } + styleSheet.textContent = styles; - const singleAccount = this.getAttribute("single-account") === "true"; - const selectedAccount = this.getAttribute("selected-account"); + const isCompactMode = this.getAttribute("compact-mode") === "true"; - if (isMobile()) { - peraWalletConnectModal.innerHTML = ` -
-
- - - -
-
- `; + if (isCompactMode) { + peraWalletConnectModalClassNames = `${peraWalletConnectModalClassNames} ${PERA_WALLET_MODAL_CLASSNAME}--compact`; + } - this.shadowRoot.append( - peraWalletConnectModal.content.cloneNode(true), - styleSheet - ); - } else { - peraWalletConnectModal.innerHTML = ` + const singleAccount = this.getAttribute("single-account") === "true"; + const selectedAccount = this.getAttribute("selected-account") || undefined; + + if (isMobile()) { + peraWalletConnectModal.innerHTML = `
-
+
- +
`; - this.shadowRoot.append( - peraWalletConnectModal.content.cloneNode(true), - styleSheet - ); - } + this.shadowRoot.append( + peraWalletConnectModal.content.cloneNode(true), + styleSheet + ); + } else { + peraWalletConnectModal.innerHTML = ` +
+
+ + + +
+
+ `; + + this.shadowRoot.append( + peraWalletConnectModal.content.cloneNode(true), + styleSheet + ); } } } diff --git a/src/modal/_pera-wallet-modal.scss b/src/modal/_pera-wallet-modal.scss index e010d2d..85fe725 100644 --- a/src/modal/_pera-wallet-modal.scss +++ b/src/modal/_pera-wallet-modal.scss @@ -32,13 +32,13 @@ &--mobile { .pera-wallet-modal__body { - top: 40px; + position: absolute; + top: unset; bottom: 0; left: 0; width: 100%; max-width: unset; - height: calc(100 * var(--pera-wallet-vh)); padding: 20px; @@ -190,13 +190,13 @@ @keyframes PeraWalletConnectMobileSlideIn { 0% { - top: 30%; + bottom: -30%; opacity: 0; } 100% { - top: 40px; + bottom: 0; opacity: 1; } diff --git a/src/modal/mode/desktop/PeraWalletConnectModalDesktopMode.ts b/src/modal/mode/desktop/PeraWalletConnectModalDesktopMode.ts index d9f6901..395fea7 100644 --- a/src/modal/mode/desktop/PeraWalletConnectModalDesktopMode.ts +++ b/src/modal/mode/desktop/PeraWalletConnectModalDesktopMode.ts @@ -13,7 +13,6 @@ import QRCodeStyling from "qr-code-styling"; import styles from "./_pera-wallet-connect-modal-desktop-mode.scss"; import accordionStyles from "./accordion/_pera-wallet-accordion.scss"; -// import {peraWalletFlowType} from "../../../util/device/deviceUtils"; const peraWalletConnectModalDesktopMode = document.createElement("template"); const styleSheet = document.createElement("style"); diff --git a/src/modal/peraWalletConnectModalUtils.ts b/src/modal/peraWalletConnectModalUtils.ts index 19e5f58..d5730b6 100644 --- a/src/modal/peraWalletConnectModalUtils.ts +++ b/src/modal/peraWalletConnectModalUtils.ts @@ -1,5 +1,6 @@ import PeraWalletConnectError from "../util/PeraWalletConnectError"; import {waitForElementCreatedAtShadowDOM} from "../util/dom/domUtils"; +import {generatePeraWalletConnectDeepLink} from "../util/peraWalletUtils"; export type PERA_CONNECT_MODAL_VIEWS = "default" | "download-pera"; @@ -11,6 +12,7 @@ export interface PeraWalletModalConfig { compactMode?: boolean; singleAccount?: boolean; selectedAccount?: string; + isInWebview?: boolean; } // The ID of the wrapper element for PeraWalletConnectModal @@ -59,20 +61,26 @@ function createModalWrapperOnDOM(modalId: string) { */ function openPeraWalletConnectModal(modalConfig: PeraWalletModalConfig) { return (uri: string) => { - if (!document.getElementById(PERA_WALLET_CONNECT_MODAL_ID)) { + const { + isWebWalletAvailable, + shouldDisplayNewBadge, + shouldUseSound, + compactMode, + promoteMobile, + singleAccount, + selectedAccount, + isInWebview + } = modalConfig; + + if (isInWebview) { + const deepLink = generatePeraWalletConnectDeepLink(uri, {singleAccount, selectedAccount}); + + window.open(deepLink, "_blank"); + } else if (!document.getElementById(PERA_WALLET_CONNECT_MODAL_ID)) { const root = createModalWrapperOnDOM(PERA_WALLET_CONNECT_MODAL_ID); const newURI = `${uri}&algorand=true`; - const { - isWebWalletAvailable, - shouldDisplayNewBadge, - shouldUseSound, - compactMode, - promoteMobile, - singleAccount, - selectedAccount - } = modalConfig; - - root.innerHTML = ``; + + root.innerHTML = ``; } }; } diff --git a/src/util/screen/setDynamicVhValue.ts b/src/util/screen/setDynamicVhValue.ts index c347a9d..6abee1d 100644 --- a/src/util/screen/setDynamicVhValue.ts +++ b/src/util/screen/setDynamicVhValue.ts @@ -1,10 +1,17 @@ import {setVhVariable} from "./screenSizeUtils"; (function setDynamicVhValue() { - window.addEventListener("DOMContentLoaded", () => { + // Call immediately if DOM is already loaded + if (document.readyState === "complete" || document.readyState === "interactive") { setVhVariable(); - }); + } else { + // Otherwise wait for DOMContentLoaded + window.addEventListener("DOMContentLoaded", () => { + setVhVariable(); + }); + } + // Always set up resize listener window.addEventListener("resize", () => { setVhVariable(); }); diff --git a/src/util/webview-api/webviewApi.ts b/src/util/webview-api/webviewApi.ts new file mode 100644 index 0000000..6b2eb9a --- /dev/null +++ b/src/util/webview-api/webviewApi.ts @@ -0,0 +1,16 @@ +/** + * Webview API Implementation + * Pera Connect WebView API functions + */ +import { callMobileMethodWithResponse } from "./webviewBridge"; +import type { PublicSettings } from "./webviewApiTypes"; + +const DEFAULT_TIMEOUT = 2000; + +/** + * Get public settings + * Returns privacy-safe subset of settings (for Pera Connect) + */ +export function getPublicSettings(timeoutMs = DEFAULT_TIMEOUT): Promise { + return callMobileMethodWithResponse("getPublicSettings", timeoutMs); +} diff --git a/src/util/webview-api/webviewApiTypes.ts b/src/util/webview-api/webviewApiTypes.ts new file mode 100644 index 0000000..2429e85 --- /dev/null +++ b/src/util/webview-api/webviewApiTypes.ts @@ -0,0 +1,114 @@ + +/** + * Webview API Types + * Type definitions for webview API communication + */ + +/** + * Platform types + */ +export type Platform = "android" | "ios" | "unknown"; + +/** + * Theme options + */ +export type Theme = "light" | "dark"; + +/** + * Network options + */ +export type Network = "mainnet" | "testnet"; + +/** + * Public settings accessible via Pera Connect (no privileged access required) + */ +export interface PublicSettings { + theme: Theme; + network: Network; + language: string; + currency: string; +} + +/** + * JSON-RPC 2.0 Error Codes + * Standard error codes as per JSON-RPC 2.0 specification + */ +export enum JsonRpcErrorCode { + /* eslint-disable no-magic-numbers */ + ParseError = -32700, + InvalidRequest = -32600, + MethodNotFound = -32601, + InvalidParams = -32602, + InternalError = -32603, + ServerErrorStart = -32000, + ServerErrorEnd = -32099 + /* eslint-enable no-magic-numbers */ +} + +/** + * JSON-RPC 2.0 Request Object + * Used for method calls that expect a response + */ +export interface JsonRpcRequest { + jsonrpc: "2.0"; + method: string; + params?: unknown; /* Can be Array (by-position) or Object (by-name) */ + id: string | number; +} + +/** + * JSON-RPC 2.0 Notification Object + * Used for method calls that don't expect a response (no id field) + */ +export interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; + // No id field - this is what makes it a notification +} + +/** + * JSON-RPC 2.0 Error Object + * Used in error responses + */ +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +/** + * JSON-RPC 2.0 Success Response Object + */ +export interface JsonRpcSuccessResponse { + jsonrpc: "2.0"; + result: unknown; + id: string | number | null; +} + +/** + * JSON-RPC 2.0 Error Response Object + */ +export interface JsonRpcErrorResponse { + jsonrpc: "2.0"; + error: JsonRpcError; + id: string | number | null; +} + +/** + * JSON-RPC 2.0 Response Object + * Union of success and error responses + */ +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; + +/** + * JSON-RPC 2.0 Batch Request + * Array of request/notification objects + */ +export type JsonRpcBatchRequest = Array; + +/** + * JSON-RPC 2.0 Batch Response + * Array of response objects (notifications don't have responses) + */ +export type JsonRpcBatchResponse = JsonRpcResponse[]; diff --git a/src/util/webview-api/webviewBridge.ts b/src/util/webview-api/webviewBridge.ts new file mode 100644 index 0000000..edb3861 --- /dev/null +++ b/src/util/webview-api/webviewBridge.ts @@ -0,0 +1,545 @@ +/* eslint-disable max-lines */ +/** + * Unified Webview Bridge + * Handles communication with both Android and iOS platforms using JSON-RPC 2.0 + * Combines JSON-RPC utilities, error handling, platform detection, message listener, and bridge functions + */ + +// Reference global types +// eslint-disable-next-line spaced-comment, @typescript-eslint/triple-slash-reference +/// + +import { + type Platform, + type JsonRpcRequest, + type JsonRpcNotification, + type JsonRpcResponse, + type JsonRpcBatchRequest, + type JsonRpcBatchResponse, + type JsonRpcError, + JsonRpcErrorCode +} from "./webviewApiTypes"; + +/** + * Mobile method names that can be called via the bridge + */ +export type MobileMethodName = "getPublicSettings"; + +// ============================================================================ +// JSON-RPC Utilities +// ============================================================================ + +/** + * Request ID generator + * Uses timestamp + incrementing counter for better uniqueness + */ +let requestIdCounter = 0; + +/** + * Generate a unique request ID + * Combines timestamp (last 8 digits) with a counter for uniqueness + * This ensures uniqueness even if counter wraps around or multiple SDK instances exist + */ +function generateRequestId(): number { + const timestamp = Date.now(); + // eslint-disable-next-line no-plusplus + const counter = requestIdCounter++; + + // Use last 8 digits of timestamp + 4-digit counter + // This gives us: YYYYMMDDHHMMSS + 0000-9999 counter + // Example: 12345678 + 0001 = 123456780001 + // eslint-disable-next-line no-magic-numbers + const timestampPart = timestamp.toString().slice(-8); + // eslint-disable-next-line no-magic-numbers + const counterPart = counter.toString().padStart(4, "0"); + + return parseInt(`${timestampPart}${counterPart}`, 10); +} + +/** + * Create a JSON-RPC 2.0 request object + */ +function createRequest( + method: string, + params?: unknown, + id?: number | string +): JsonRpcRequest { + const requestId = id ?? generateRequestId(); + + return { + jsonrpc: "2.0", + method, + ...(params !== undefined && { params }), + id: requestId + }; +} + +/** + * Type guard: Check if object is a JSON-RPC response (success or error) + */ +function isJsonRpcResponse(obj: unknown): obj is JsonRpcResponse { + if (!obj || typeof obj !== "object") { + return false; + } + + const resp = obj as Record; + + return ( + resp.jsonrpc === "2.0" && + ("result" in resp || "error" in resp) && + (resp.id === null || typeof resp.id === "string" || typeof resp.id === "number") + ); +} + +/** + * Type guard: Check if object is a JSON-RPC batch response + */ +function isJsonRpcBatchResponse(obj: unknown): obj is JsonRpcBatchResponse { + return Array.isArray(obj) && obj.every((item) => isJsonRpcResponse(item)); +} + +/** + * Type guard: Check if object is a JSON-RPC notification + */ +function isJsonRpcNotification(obj: unknown): obj is JsonRpcNotification { + if (!obj || typeof obj !== "object") { + return false; + } + + const notif = obj as Record; + + return ( + notif.jsonrpc === "2.0" && + typeof notif.method === "string" && + !("id" in notif) + ); +} + +// ============================================================================ +// JSON-RPC Error Handling +// ============================================================================ + +/** + * Base class for JSON-RPC errors + */ +class JsonRpcErrorClass extends Error { + public readonly code: number; + public readonly data?: unknown; + + constructor(error: JsonRpcError) { + super(error.message); + this.name = "JsonRpcError"; + this.code = error.code; + this.data = error.data; + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, JsonRpcErrorClass); + } + } + + /** + * Get error message with code + */ + getMessage(): string { + return `[${this.code}] ${this.message}`; + } +} + +/** + * Convert JSON-RPC error object to appropriate error class instance + */ +function createJsonRpcError(error: JsonRpcError): JsonRpcErrorClass { + switch (error.code) { + case JsonRpcErrorCode.ParseError: + return new JsonRpcErrorClass({ + code: JsonRpcErrorCode.ParseError, + message: "Parse error", + data: error.data + }); + case JsonRpcErrorCode.InvalidRequest: + return new JsonRpcErrorClass({ + code: JsonRpcErrorCode.InvalidRequest, + message: "Invalid Request", + data: error.data + }); + case JsonRpcErrorCode.MethodNotFound: + return new JsonRpcErrorClass({ + code: JsonRpcErrorCode.MethodNotFound, + message: error.message || "Method not found", + data: error.data + }); + case JsonRpcErrorCode.InvalidParams: + return new JsonRpcErrorClass({ + code: JsonRpcErrorCode.InvalidParams, + message: "Invalid params", + data: error.data + }); + case JsonRpcErrorCode.InternalError: + return new JsonRpcErrorClass({ + code: JsonRpcErrorCode.InternalError, + message: "Internal error", + data: error.data + }); + default: + // Server errors (-32000 to -32099) or custom errors + if ( + error.code >= JsonRpcErrorCode.ServerErrorStart && + error.code <= JsonRpcErrorCode.ServerErrorEnd + ) { + return new JsonRpcErrorClass(error); + } + // Custom application errors + return new JsonRpcErrorClass(error); + } +} + +// ============================================================================ +// Platform Detection +// ============================================================================ + +/** + * Detect the current platform + * Reserved for future use + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function detectPlatform(): Platform { + if (typeof window === "undefined") { + return "unknown"; + } + + if (window.peraMobileInterface) { + return "android"; + } + + if (window.webkit?.messageHandlers) { + return "ios"; + } + + return "unknown"; +} + +/** + * Get Android interface if available + */ +function getAndroidInterface() { + return window.peraMobileInterface as PeraMobileAndroidInterface | undefined; +} + +/** + * Get iOS interface if available + */ +function getIosInterface() { + return window.webkit?.messageHandlers as PeraMobileIosInterface | undefined; +} + +// ============================================================================ +// Message Listener +// ============================================================================ + +type ActionHandler = (payload: T) => void; + +interface PendingRequest { + resolve: (value: T) => void; + reject: (error: Error) => void; + timeoutHandle: ReturnType; + method: string; +} + +class MessageListener { + private listeners: Map>> = new Map(); + private pendingRequests: Map> = new Map(); + private isListening = false; + + /** + * Initialize the global message listener (only once) + */ + private startListening(): void { + if (this.isListening) { + return; + } + + window.addEventListener("message", this.handleMessage.bind(this)); + this.isListening = true; + } + + /** + * Handle incoming messages from mobile + * Supports JSON-RPC 2.0 format + */ + private handleMessage(event: MessageEvent): void { + try { + // Parse event.data as JSON + const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data; + + if (!data || typeof data !== "object") { + return; + } + + // Handle JSON-RPC 2.0 batch response + if (isJsonRpcBatchResponse(data)) { + this.handleBatchResponse(data); + return; + } + + // Handle JSON-RPC 2.0 single response + if (isJsonRpcResponse(data)) { + this.handleJsonRpcResponse(data); + return; + } + + // Handle JSON-RPC 2.0 notification (for event-based subscriptions) + if (isJsonRpcNotification(data)) { + this.handleJsonRpcNotification(data); + } + } catch (error) { + // Silently ignore parsing errors + // This prevents errors from malformed messages breaking the app + } + } + + /** + * Handle JSON-RPC 2.0 batch response + */ + private handleBatchResponse(batchResponse: JsonRpcBatchResponse): void { + batchResponse.forEach((response) => { + this.handleJsonRpcResponse(response); + }); + } + + /** + * Handle JSON-RPC 2.0 response (success or error) + */ + private handleJsonRpcResponse(response: JsonRpcResponse): void { + const { id } = response; + + // Responses with null id are invalid (except for parse errors) + if (id === null) { + return; + } + + const pendingRequest = this.pendingRequests.get(id); + + if (!pendingRequest) { + return; + } + + clearTimeout(pendingRequest.timeoutHandle); + this.pendingRequests.delete(id); + + // Handle error response + if ("error" in response) { + const error = createJsonRpcError(response.error); + + pendingRequest.reject(error); + + return; + } + + // Handle success response + if ("result" in response) { + try { + const parsedResult = this.parsePayload(response.result); + + pendingRequest.resolve(parsedResult as never); + } catch (error) { + pendingRequest.reject( + error instanceof Error + ? error + : new Error(`Failed to parse result for method ${pendingRequest.method}`) + ); + } + } + } + + /** + * Handle JSON-RPC 2.0 notification (for event-based subscriptions) + */ + private handleJsonRpcNotification(notification: { method: string; params?: unknown }): void { + const { method, params } = notification; + const handlers = this.listeners.get(method); + + if (handlers) { + const parsedParams = this.parsePayload(params); + + handlers.forEach((handler) => { + try { + handler(parsedParams); + } catch (error) { + console.error(`Error in handler for method ${method}:`, error); + } + }); + } + } + + /** + * Parse payload - handles base64 decoding and JSON parsing + */ + private parsePayload(payload: unknown): unknown { + if (typeof payload === "string") { + // Try base64 decode first + try { + const decoded = window.atob(payload); + + return JSON.parse(decoded); + } catch { + // If base64 decode fails, try direct JSON parse + try { + return JSON.parse(payload); + } catch { + // If both fail, return as-is + return payload; + } + } + } + + // If payload is already an object, return as-is + return payload; + } + + /** + * Register a one-time listener for a JSON-RPC response by ID + * Returns a promise that resolves when the response is received + */ + // eslint-disable-next-line no-magic-numbers + waitForResponse(id: string | number, method: string, timeoutMs = 5000): Promise { + this.startListening(); + + return new Promise((resolve, reject) => { + // Check if there's already a pending request for this ID + const existing = this.pendingRequests.get(id); + + if (existing) { + clearTimeout(existing.timeoutHandle); + existing.reject(new Error(`New request for ${method} (id: ${id}) cancelled previous request`)); + } + + const timeoutHandle = setTimeout(() => { + this.pendingRequests.delete(id); + reject(new Error(`Timeout waiting for response from ${method} (id: ${id})`)); + }, timeoutMs); + + this.pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + timeoutHandle, + method + }); + }); + } + + /** + * Register a persistent listener for a specific action/method + * Useful for event-based subscriptions (e.g., onBackPressed) + * Works with JSON-RPC 2.0 notifications (method field) + */ + onAction(action: string, handler: ActionHandler): () => void { + this.startListening(); + + if (!this.listeners.has(action)) { + this.listeners.set(action, new Set()); + } + + const handlers = this.listeners.get(action)!; + + handlers.add(handler as ActionHandler); + + // Return unsubscribe function + return () => { + handlers.delete(handler as ActionHandler); + if (handlers.size === 0) { + this.listeners.delete(action); + } + }; + } + + /** + * Remove all listeners and pending requests + * Useful for cleanup + */ + cleanup(): void { + // Reject all pending requests + this.pendingRequests.forEach((request) => { + clearTimeout(request.timeoutHandle); + request.reject(new Error("Message listener cleaned up")); + }); + this.pendingRequests.clear(); + + // Clear all listeners + this.listeners.clear(); + + // Remove global listener + if (this.isListening) { + window.removeEventListener("message", this.handleMessage.bind(this)); + this.isListening = false; + } + } +} + +const messageListener = new MessageListener(); + +// ============================================================================ +// Bridge Functions +// ============================================================================ + +/** + * Send a JSON-RPC message to the mobile interface + * Works for both Android and iOS + * Uses unified handleRequest function for both single requests/notifications and batch requests + */ +function sendJsonRpcMessage(message: JsonRpcRequest | JsonRpcNotification | JsonRpcBatchRequest): void { + const androidInterface = getAndroidInterface(); + const iosInterface = getIosInterface(); + + // Always stringify the message (single object or array) + const stringifiedMessage = JSON.stringify(message); + + // Send to Android interface using handleRequest + if (androidInterface?.handleRequest) { + androidInterface.handleRequest(stringifiedMessage); + } + + // Send to iOS interface using handleRequest.postMessage + if (iosInterface?.handleRequest?.postMessage) { + iosInterface.handleRequest.postMessage(stringifiedMessage); + } +} + +/** + * Call a mobile method that expects a response + * Sends a JSON-RPC 2.0 request and waits for response via postMessage + * Returns a promise that resolves when the matching response is received + */ +export function callMobileMethodWithResponse( + methodName: MobileMethodName, + // eslint-disable-next-line no-magic-numbers + timeoutMs = 5000, + params?: unknown +): Promise { + // Generate unique request ID + const requestId = generateRequestId(); + + // Register listener for the response before sending the message + const responsePromise = messageListener.waitForResponse(requestId, methodName, timeoutMs); + + // Create and send JSON-RPC request + const request = createRequest(methodName, params, requestId); + + sendJsonRpcMessage(request); + + return responsePromise; +} + +/** + * Check if the mobile interface is available + * With the unified handleRequest interface, method availability is handled by the mobile app + */ +export function isMobileMethodAvailable(): boolean { + const androidInterface = getAndroidInterface(); + const iosInterface = getIosInterface(); + + // Check if handleRequest exists (method availability is now handled by mobile app) + return Boolean( + androidInterface?.handleRequest || + iosInterface?.handleRequest?.postMessage + ); +} diff --git a/src/util/webview-api/webviewBridgeTypes.d.ts b/src/util/webview-api/webviewBridgeTypes.d.ts new file mode 100644 index 0000000..0865250 --- /dev/null +++ b/src/util/webview-api/webviewBridgeTypes.d.ts @@ -0,0 +1,54 @@ +/** + * Global type definitions for Pera mobile interfaces + * Augments the Window interface with platform-specific mobile bridges + */ + +declare global { + interface Window { + /** + * Android WebView interface injected by Pera mobile app + * Available when running in Android WebView + */ + peraMobileInterface?: PeraMobileAndroidInterface; + + /** + * iOS WKWebView message handlers injected by Pera mobile app + * Available when running in iOS WKWebView + */ + webkit?: { + messageHandlers?: PeraMobileIosInterface; + }; + } + + /** + * Android interface - unified request handler + * Receives JSON-RPC 2.0 request/notification strings (or batch request arrays as JSON strings) + * The mobile app should respond with JSON-RPC 2.0 response objects via window.postMessage + */ + interface PeraMobileAndroidInterface { + /** + * Unified method for handling all JSON-RPC 2.0 requests and notifications + * Receives a JSON string containing either: + * - A single JSON-RPC request/notification object + * - An array of JSON-RPC requests/notifications (batch request) + */ + handleRequest?: (jsonRpcMessage: string) => void; + } + + /** + * iOS interface - unified request handler + * Receives JSON-RPC 2.0 request/notification strings (or batch request arrays as JSON strings) + * The mobile app should respond with JSON-RPC 2.0 response objects via window.postMessage + */ + interface PeraMobileIosInterface { + /** + * Unified method for handling all JSON-RPC 2.0 requests and notifications + * Receives a JSON string containing either: + * - A single JSON-RPC request/notification object + * - An array of JSON-RPC requests/notifications (batch request) + */ + handleRequest?: { postMessage: (jsonRpcMessage: string) => void }; + } +} + +export {};