From d0db097631ff696de999387262f6875a9dfd188a Mon Sep 17 00:00:00 2001 From: Cesar Rodas Date: Sun, 5 Jan 2025 19:15:43 -0300 Subject: [PATCH] Experiment to have an easier integration with cashu through web wallet This PR is not meant to be merged, but instead to be used as an example to create the foundation for embeddabe wallets to use cashu in all the internet safely. Context and explanation https://www.youtube.com/watch?v=lp5K28W5VaY --- public/embed-example.html | 21 ++++ src/embed-client.ts | 151 ++++++++++++++++++++++++++++ src/js/broadcast_channel.ts | 29 ++++++ src/js/embedded.ts | 115 +++++++++++++++++++++ src/layouts/EmbeddedLayout.vue | 24 +++++ src/pages/WalletPage.vue | 116 ++++----------------- src/pages/embedded/RequestFunds.vue | 87 ++++++++++++++++ src/router/routes.js | 9 ++ 8 files changed, 455 insertions(+), 97 deletions(-) create mode 100644 public/embed-example.html create mode 100644 src/embed-client.ts create mode 100644 src/js/broadcast_channel.ts create mode 100644 src/js/embedded.ts create mode 100644 src/layouts/EmbeddedLayout.vue create mode 100644 src/pages/embedded/RequestFunds.vue diff --git a/public/embed-example.html b/public/embed-example.html new file mode 100644 index 0000000..e5e3ebc --- /dev/null +++ b/public/embed-example.html @@ -0,0 +1,21 @@ + +

This is a site, click here to request funds: +

+ + + diff --git a/src/embed-client.ts b/src/embed-client.ts new file mode 100644 index 0000000..f2a64a7 --- /dev/null +++ b/src/embed-client.ts @@ -0,0 +1,151 @@ +type RequestFundsPayload = { + asset: string; + amount: number; +}; + +type RequestFundsResponse = { + proofs: string[], +}; + +class IframeOverlayManager { + private iframe: HTMLIFrameElement; + private backdrop: HTMLDivElement; + private isVisible: boolean = false; + private booting: boolean = true; + private bootInterval: any; + + constructor(iframeSrc: string) { + this.backdrop = document.createElement("div"); + this.backdrop.style.position = "fixed"; + this.backdrop.style.top = "0"; + this.backdrop.style.left = "0"; + this.backdrop.style.width = "100vw"; + this.backdrop.style.height = "100vh"; + this.backdrop.style.backgroundColor = "rgba(0, 0, 0, 0.5)"; + this.backdrop.style.zIndex = "9998"; + this.backdrop.style.display = "none"; + + this.iframe = document.createElement("iframe"); + this.iframe.src = iframeSrc; + this.iframe.style.position = "fixed"; + this.iframe.style.top = "0"; + this.iframe.style.left = "0"; + this.iframe.style.width = "400px"; + this.iframe.style.height = "300px"; + this.iframe.style.border = "none"; + this.iframe.style.zIndex = "9999"; + this.iframe.style.display = "none"; // Hidden by default + this.iframe.style.background = "white"; // Optional: Set a background color + + document.body.appendChild(this.backdrop); + document.body.appendChild(this.iframe); + + // Listen for messages from the iframe + window.addEventListener("message", this.handleMessage.bind(this)); + } + + private handleMessage(event: MessageEvent) { + const { action, ...payload } = event.data || {}; + + if (action === "show") { + this.showIframe(); + } else if (action === "hide") { + this.hideIframe(); + } else if (action === "proofs") { + this.handleRequestFundsResponse(payload); + } else if (action === "ready") { + this.booting = false; + clearInterval(this.bootInterval); + } + } + + private showIframe() { + if (!this.isVisible) { + + // Calculate viewport dimensions + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Calculate positions to center the iframe + const left = (viewportWidth - parseInt(this.iframe.style.width)) / 2; // 200px width + const top = (viewportHeight - parseInt(this.iframe.style.height)) / 2; // 200px height + + // Apply calculated positions + this.iframe.style.left = `${left}px`; + this.iframe.style.top = `${top}px`; + + this.iframe.style.display = "block"; + this.backdrop.style.display = "block"; + this.isVisible = true; + console.log("IframeOverlayManager: Iframe is now visible."); + } + } + + private hideIframe() { + if (this.isVisible) { + this.iframe.style.display = "none"; + this.backdrop.style.display = "none"; + this.isVisible = false; + console.log("IframeOverlayManager: Iframe is now hidden."); + } + } + + boot() { + if (!this.iframe.contentWindow) { + const that = this; + setTimeout(() => { + that.boot(); + }) + return; + } + + if (this.bootInterval) { + clearInterval(this.bootInterval); + } + + this.bootInterval = setInterval(() => { + this.iframe.contentWindow?.postMessage({ action: "ping" }, "*"); + }, 1); + } + + /** + * Requests funds from the iframe and handles the response. + * @param payload - The payload containing asset and amount. + * @param onResponse - A callback function to handle the response. + */ + requestFunds(payload: RequestFundsPayload, onResponse: (response: RequestFundsResponse) => void) { + if (this.booting) { + const that = this; + return setTimeout(() => { + that.requestFunds(payload, onResponse); + }) + + } + + // Send the request-funds message to the iframe + this.iframe.contentWindow?.postMessage( + { + action: "request-funds", + payload, + }, + "*" + ); + + // Store the callback to handle the response + this.requestFundsCallback = onResponse; + } + + private requestFundsCallback: ((response: RequestFundsResponse) => void) | null = null; + + private handleRequestFundsResponse(response: RequestFundsResponse) { + if (this.requestFundsCallback) { + this.requestFundsCallback(response); + this.requestFundsCallback = null; // Clear the callback after handling + } + } +} + +// Example usage +const iframeSrc = "https://localhost:8080/embedded"; +window.cashu = new IframeOverlayManager(iframeSrc); +window.cashu.boot(); diff --git a/src/js/broadcast_channel.ts b/src/js/broadcast_channel.ts new file mode 100644 index 0000000..6745ddb --- /dev/null +++ b/src/js/broadcast_channel.ts @@ -0,0 +1,29 @@ + +export function registerBroadcastChannel(router: any) { + // uses session storage to identify the tab so we can ignore incoming messages from the same tab + if (!sessionStorage.getItem("tabId")) { + sessionStorage.setItem( + "tabId", + Math.random().toString(36).substring(2) + + new Date().getTime().toString(36) + ); + } + + const tabId = sessionStorage.getItem("tabId"); + const channel = new BroadcastChannel("app_channel"); + const announcement = { type: "new_tab_opened", senderId: tabId }; + + channel.onmessage = async (event) => { + // console.log("Received message in tab " + tabId, event.data); + if (event.data.senderId === tabId) { + return; // Ignore the message if it comes from the same tab + } + if (event.data.type == "new_tab_opened") { + channel.postMessage({ type: "already_running", senderId: tabId }); + } else if (event.data.type == "already_running") { + router.push("/already-running"); + } + }; + + channel.postMessage(announcement); +} diff --git a/src/js/embedded.ts b/src/js/embedded.ts new file mode 100644 index 0000000..a721588 --- /dev/null +++ b/src/js/embedded.ts @@ -0,0 +1,115 @@ +/** + * The event that is sent when the user requests funds from the parent window to the embedded wallet + */ +type RequestFundsEvent = { + action: "request-funds"; + asset: string; + amount: number; +}; + +type ResponseFundsEvent = { + action: 'proofs', + proofs: string[], +} + +/** + * The wallet response event that is sent when the wallet has processed the request funds event + * and that the parent window should make the embeded wallet visible to proceed with the transaction, + * after getting user approval + */ +type ResponseShowEvent = { + action: "show", +} + +/** + * Hide iframe + */ +type ResponseHideEvent = { + action: "hide", +} + +type ResponseReadyEvent = { + action: "ready" +}; + +type MessageHandlers = { + [key: string]: (payload: any) => void; +}; + +export class EmbeddedHandler { + public readonly isEmbedded: boolean; + private messageHandlers: MessageHandlers = {}; + private parentOrigin: string | null = null; + + constructor() { + this.isEmbedded = window.self !== window.top; + if (!this.isEmbedded) { + console.error("EmbeddedHandler instantiated but disabled since not running in an iframe") + return; + } + window.addEventListener("message", this.handleMessage.bind(this)); + } + + boot(router: any) { + + } + + /** + * Sends a message to the parent window. + * @param message - The message to send. + */ + sendMessage(message: T) { + if (!this.parentOrigin) { + throw new Error('EmbeddedHandler: Parent origin not set.'); + } + window.parent.postMessage(message, this.parentOrigin); + } + + sendProofs(proofs: string[]) { + this.sendMessage({ "action": "proofs", proofs }) + } + + makeVisible() { + this.sendMessage({ "action": "show" }) + } + + makeHidden() { + this.sendMessage({ "action": "hide" }) + } + + ready() { + this.sendMessage({ "action": "ready" }) + } + + private handleMessage(event: MessageEvent) { + const { action, payload } = event.data || {}; + if (!action || !this.messageHandlers[action]) { + console.warn(`EmbeddedHandler: No handler found for message type "${action}".`); + return; + } + + if (!this.parentOrigin) { + this.parentOrigin = event.origin; + } + + try { + this.messageHandlers[action](payload); + } catch (error) { + console.error(`EmbeddedHandler: Error handling message type "${action}"`, error); + } + } + + /** + * Registers a handler for a specific message type. + * @param type - The type of the message to handle. + * @param handler - The function to handle the message. + */ + registerHandler(type: string, handler: (payload: T) => void) { + if (this.messageHandlers[type]) { + console.warn(`EmbeddedHandler: Overwriting existing handler for type "${type}".`); + } + this.messageHandlers[type] = handler; + } +} + +export default new EmbeddedHandler diff --git a/src/layouts/EmbeddedLayout.vue b/src/layouts/EmbeddedLayout.vue new file mode 100644 index 0000000..3021978 --- /dev/null +++ b/src/layouts/EmbeddedLayout.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/pages/WalletPage.vue b/src/pages/WalletPage.vue index 7339787..e46c1f2 100644 --- a/src/pages/WalletPage.vue +++ b/src/pages/WalletPage.vue @@ -4,51 +4,26 @@ -
+
- + - Receive + Receive
- +
- + - Send + Send
@@ -59,33 +34,17 @@ - + - + - + @@ -109,18 +68,9 @@
- InstallInstall Cashu + InstallInstall Cashu
@@ -142,13 +92,8 @@ - + @@ -224,6 +169,7 @@ import ReceiveTokenDialog from "src/components/ReceiveTokenDialog.vue"; import { useWelcomeStore } from "../stores/welcome"; import { useInvoicesWorkerStore } from "src/stores/invoicesWorker"; import { notifyError, notify } from "../js/notify"; +import { registerBroadcastChannel } from "src/js/broadcast_channel.ts" import { X as XIcon, @@ -524,30 +470,6 @@ export default { } }); }, - registerBroadcastChannel: async function () { - // uses session storage to identify the tab so we can ignore incoming messages from the same tab - if (!sessionStorage.getItem("tabId")) { - sessionStorage.setItem( - "tabId", - Math.random().toString(36).substring(2) + - new Date().getTime().toString(36) - ); - } - const tabId = sessionStorage.getItem("tabId"); - const channel = new BroadcastChannel("app_channel"); - channel.postMessage({ type: "new_tab_opened", senderId: tabId }); - channel.onmessage = async (event) => { - // console.log("Received message in tab " + tabId, event.data); - if (event.data.senderId === tabId) { - return; // Ignore the message if it comes from the same tab - } - if (event.data.type == "new_tab_opened") { - channel.postMessage({ type: "already_running", senderId: tabId }); - } else if (event.data.type == "already_running") { - this.$router.push("/already-running"); - } - }; - }, }, watch: {}, @@ -559,7 +481,7 @@ export default { created: async function () { // check if another tab is open - this.registerBroadcastChannel(); + registerBroadcastChannel(this.$router); let params = new URL(document.location).searchParams; let hash = new URL(document.location).hash; diff --git a/src/pages/embedded/RequestFunds.vue b/src/pages/embedded/RequestFunds.vue new file mode 100644 index 0000000..7a4e090 --- /dev/null +++ b/src/pages/embedded/RequestFunds.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/router/routes.js b/src/router/routes.js index 08429be..dd5eb7b 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -1,3 +1,4 @@ + const routes = [ { path: "/", @@ -6,6 +7,14 @@ const routes = [ { path: "", component: () => import("src/pages/WalletPage.vue") }, ], }, + { + path: "/embedded", + component: () => import("layouts/EmbeddedLayout.vue") + }, + { + path: "/embedded/request-funds/:asset/:amount", + component: () => import("pages/embedded/RequestFunds.vue") + }, { path: "/settings", component: () => import("layouts/FullscreenLayout.vue"),