Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experiment to have an easier integration with cashu through web wallet #313

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions public/embed-example.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<body>
<h1>This is a site, click here to request funds: <button onclick="request()">request funds</button>
</h1>
</body>
<script src="https://localhost:8080/src/embed-client.ts"></script>
<script>
function request() {

// Example: Request funds
window.cashu.requestFunds(
{ asset: "sat", amount: parseInt(Math.random() * 100000) },
(response) => {
if (response.proofs) {
alert("Funds request approved, use these proofs: " + response.proofs.join(","));
} else {
console.log("Funds request rejected");
}
}
);
}
</script>
151 changes: 151 additions & 0 deletions src/embed-client.ts
Original file line number Diff line number Diff line change
@@ -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();
29 changes: 29 additions & 0 deletions src/js/broadcast_channel.ts
Original file line number Diff line number Diff line change
@@ -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);
}
115 changes: 115 additions & 0 deletions src/js/embedded.ts
Original file line number Diff line number Diff line change
@@ -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<T>(message: T) {
if (!this.parentOrigin) {
throw new Error('EmbeddedHandler: Parent origin not set.');
}
window.parent.postMessage(message, this.parentOrigin);
}

sendProofs(proofs: string[]) {
this.sendMessage<ResponseFundsEvent>({ "action": "proofs", proofs })
}

makeVisible() {
this.sendMessage<ResponseShowEvent>({ "action": "show" })
}

makeHidden() {
this.sendMessage<ResponseHideEvent>({ "action": "hide" })
}

ready() {
this.sendMessage<ResponseReadyEvent>({ "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<T>(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
24 changes: 24 additions & 0 deletions src/layouts/EmbeddedLayout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<template>
<div></div>
</template>

<script>
import { defineComponent, ref } from "vue";
import handler from "src/js/embedded.ts";

export default defineComponent({
name: "EmbeddedLayout",
mounted() {
if (!handler.isEmbedded) {
this.$router.push("/")
return;
}
handler.registerHandler("ping", (payload) => {
handler.ready();
});
handler.registerHandler("request-funds", (payload) => {
this.$router.push(`embedded/request-funds/${payload.asset}/${payload.amount}`)
});
},
});
</script>
Loading
Loading