Skip to content
104 changes: 80 additions & 24 deletions src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,45 @@ import {
RPC_STORAGE_MESSAGE_TYPE,
consumeRpcPayload,
} from "@/utils/rpc-storage";
import {
SESSION_INFO_STORAGE_KEY,
isSessionInfoMessage,
sanitizeSessionInfo,
} from "@/utils/session-info";
import { getHost } from "../../utils";

export default defineBackground(() => {
RpcStorageBridge.register();
SessionInfoBridge.register();
ContextMenu.create();
});

const allowedOrigin = new URL(getHost(import.meta.env.MODE)).origin;

type MessageSender = Parameters<
Parameters<typeof browser.runtime.onMessageExternal.addListener>[0]
>[1];

function isAllowedSender(sender: MessageSender) {
const senderOrigin = getSenderOrigin(sender);
if (!senderOrigin) return false;
return senderOrigin === allowedOrigin;
}

function getSenderOrigin(sender: MessageSender) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly just reshuffling existing code here

if (typeof sender.origin === "string" && sender.origin.length > 0) {
return sender.origin;
}
if (typeof sender.url === "string" && sender.url.length > 0) {
try {
return new URL(sender.url).origin;
} catch {
return null;
}
}
return null;
}

namespace ContextMenu {
export function create() {
browser.contextMenus.create({
Expand All @@ -27,11 +59,6 @@ namespace ContextMenu {
}

namespace RpcStorageBridge {
const allowedOrigin = new URL(getHost(import.meta.env.MODE)).origin;
type MessageSender = Parameters<
Parameters<typeof browser.runtime.onMessageExternal.addListener>[0]
>[1];

export function register() {
browser.runtime.onMessageExternal.addListener(handleExternalMessage);
}
Expand Down Expand Up @@ -66,29 +93,58 @@ namespace RpcStorageBridge {
);
}

function isAllowedSender(sender: MessageSender) {
const senderOrigin = getSenderOrigin(sender);
if (!senderOrigin) return false;
return senderOrigin === allowedOrigin;
async function fetchStoredPayload(token: string) {
const value = await consumeRpcPayload(token);
if (!value) return { ok: false };
return { ok: true, value };
}
}

function getSenderOrigin(sender: MessageSender) {
if (typeof sender.origin === "string" && sender.origin.length > 0) {
return sender.origin;
}
if (typeof sender.url === "string" && sender.url.length > 0) {
try {
return new URL(sender.url).origin;
} catch {
return null;
}
// Lets the Splits Teams app keep the popup's session display in sync. The app
// posts `{ type: "splits-connect:setSessionInfo", sessionInfo }` to its own
// window whenever auth state resolves, and `sessionInfo: null` when it
// resolves signed out; the content script relays it here. Sender pages are
// re-checked against the Teams origin before anything is stored.
//
// Spoofing is accepted by design: any script running on the Teams origin
// (third-party JS, other extensions' content scripts) can post this message
// and repaint the popup's display strings. The payload is sanitized below
// and the popup renders text only, so there is no injection path and nothing
// privileged to reach — and anything running on that origin can already read
// the real session from the page.
namespace SessionInfoBridge {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here is the net new stuff

export function register() {
browser.runtime.onMessage.addListener(handleMessage);
}

function handleMessage(
message: unknown,
sender: MessageSender,
sendResponse: (response: unknown) => void
) {
if (!isSessionInfoMessage(message)) return undefined;
if (!isAllowedSender(sender)) {
sendResponse({ ok: false });
return undefined;
}
return null;
const sessionInfo = sanitizeSessionInfo(
(message as { sessionInfo?: unknown }).sessionInfo
);
void persistSessionInfo(sessionInfo)
.then(() => sendResponse({ ok: true }))
.catch(() => sendResponse({ ok: false }));
return true;
}

async function fetchStoredPayload(token: string) {
const value = await consumeRpcPayload(token);
if (!value) return { ok: false };
return { ok: true, value };
async function persistSessionInfo(
sessionInfo: ReturnType<typeof sanitizeSessionInfo>
) {
if (!sessionInfo) {
await browser.storage.local.remove(SESSION_INFO_STORAGE_KEY);
return;
}
await browser.storage.local.set({
[SESSION_INFO_STORAGE_KEY]: sessionInfo,
});
}
}
26 changes: 26 additions & 0 deletions src/entrypoints/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
} from "@/utils/provider-events";
import { getProviderInfo } from "@/utils/provider-info";
import { maybeOffloadLargeRpc } from "@/utils/rpc-offload";
import {
SESSION_INFO_MESSAGE_TYPE,
isSessionInfoMessage,
} from "@/utils/session-info";
import { Chains, Dialog, Mode, Porto } from "@splits/porto";
import { tempo, worldchain } from "viem/chains";
import { getHost, getRelay } from "../../utils";
Expand All @@ -22,11 +26,33 @@ export default defineContentScript({
main() {
const bridge = new ContentBridge(window);
bridge.start();
startSessionInfoRelay(window);
},
matches: ["https://*/*", "http://localhost/*"],
runAt: "document_start",
});

// Relays session info posted by the Splits Teams app to the background
// script, which persists it for the popup. Only attached on the Teams
// origin; the background re-checks the sender origin before storing.
function startSessionInfoRelay(targetWindow: Window) {
const allowedOrigin = new URL(getHost(import.meta.env.MODE)).origin;
if (targetWindow.location.origin !== allowedOrigin) return;
targetWindow.addEventListener("message", (event) => {
if (event.source !== targetWindow) return;
if (event.origin !== allowedOrigin) return;
if (!isSessionInfoMessage(event.data)) return;
browser.runtime
.sendMessage({
sessionInfo: (event.data as { sessionInfo?: unknown }).sessionInfo,
type: SESSION_INFO_MESSAGE_TYPE,
})
.catch(() => {
// Background may be restarting; the next update will land.
});
});
}

class ContentBridge {
private readonly pendingRequests: BridgeRequestMessage[] = [];
private readonly eventHandlers: Array<
Expand Down
47 changes: 47 additions & 0 deletions src/entrypoints/popup/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Splits Connect</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<header class="header">
<img class="header-logo" src="/logo.svg" alt="" />
<span class="header-title">Splits Connect</span>
</header>
<main class="content">
<section id="session" class="session" hidden></section>
<p class="welcome-text">
This extension allows you to connect Splits to third-party
applications. There is nothing you need to do here. If Splits doesn't
show up in the application's wallet list, or you have any other
questions, please reach out to
<a href="mailto:support@splits.org">support@splits.org</a>.
</p>
</main>
<footer class="footer">
<a
href="https://www.notion.so/splits/Connecting-to-other-apps-29af7c3c8eff802fb110e7b9aa316488?source=copy_link"
target="_blank"
rel="noreferrer"
>Learn more about Splits Connect &nearr;</a
>
</footer>

<template id="signed-in">
<h2 class="session-label">Signed in as</h2>
<div class="user">
<div class="avatar avatar-fallback"></div>
<img class="avatar avatar-image" alt="" hidden />
<div class="user-details">
<div class="user-name"></div>
<div class="user-email"></div>
</div>
</div>
</template>

<script type="module" src="./main.ts"></script>
</body>
</html>
71 changes: 71 additions & 0 deletions src/entrypoints/popup/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
SESSION_INFO_STORAGE_KEY,
isSessionInfoFresh,
type SessionInfo,
} from "@/utils/session-info";

const session = document.getElementById("session");
if (session) {
void readSessionInfo().then((sessionInfo) => render(session, sessionInfo));
browser.storage.local.onChanged.addListener((changes) => {
if (!(SESSION_INFO_STORAGE_KEY in changes)) return;
const next = changes[SESSION_INFO_STORAGE_KEY]?.newValue as
| SessionInfo
| undefined;
render(session, next ?? null);
});
}

async function readSessionInfo(): Promise<SessionInfo | null> {
const stored = await browser.storage.local.get(SESSION_INFO_STORAGE_KEY);
return (stored[SESSION_INFO_STORAGE_KEY] as SessionInfo | undefined) ?? null;
}

// The welcome message is static in the HTML and always visible; this only
// fills (or hides) the "Signed in as" card below it.
function render(target: HTMLElement, sessionInfo: SessionInfo | null) {
const fresh =
sessionInfo && isSessionInfoFresh(sessionInfo) ? sessionInfo : null;
if (!fresh) {
target.replaceChildren();
target.hidden = true;
return;
}
target.replaceChildren(renderUser(fresh.user));
target.hidden = false;
}

function renderUser(user: SessionInfo["user"]) {
const template = document.getElementById("signed-in") as HTMLTemplateElement;
const view = template.content.cloneNode(true) as DocumentFragment;

setText(view, ".user-name", user.name ?? user.email ?? "");
if (user.name && user.email) setText(view, ".user-email", user.email);
else view.querySelector(".user-email")?.remove();

const source = user.name ?? user.email ?? "";
setText(view, ".avatar-fallback", source.slice(0, 1).toUpperCase());

// The fallback initial shows until the avatar image actually loads; a
// broken or missing URL never swaps it out.
const image = view.querySelector<HTMLImageElement>(".avatar-image");
const fallback = view.querySelector<HTMLElement>(".avatar-fallback");
if (image && fallback && user.avatarUrl) {
image.addEventListener(
"load",
() => {
image.hidden = false;
fallback.remove();
},
{ once: true }
);
image.src = user.avatarUrl;
}

return view;
}

function setText(view: DocumentFragment, selector: string, text: string) {
const element = view.querySelector(selector);
if (element) element.textContent = text;
}
Loading