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"),