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 @@
+
+
+
Request Funds
+
+ You are requesting funds for the asset: {{ asset }} with
+ the amount: {{ amount }}.
+
+
+
+
+
+
+
+
+
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"),