diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 2e9c98d..3e06efa 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -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[0] +>[1]; + +function isAllowedSender(sender: MessageSender) { + const senderOrigin = getSenderOrigin(sender); + if (!senderOrigin) return false; + return senderOrigin === allowedOrigin; +} + +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; + } + } + return null; +} + namespace ContextMenu { export function create() { browser.contextMenus.create({ @@ -27,11 +59,6 @@ namespace ContextMenu { } namespace RpcStorageBridge { - const allowedOrigin = new URL(getHost(import.meta.env.MODE)).origin; - type MessageSender = Parameters< - Parameters[0] - >[1]; - export function register() { browser.runtime.onMessageExternal.addListener(handleExternalMessage); } @@ -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 { + 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 + ) { + if (!sessionInfo) { + await browser.storage.local.remove(SESSION_INFO_STORAGE_KEY); + return; + } + await browser.storage.local.set({ + [SESSION_INFO_STORAGE_KEY]: sessionInfo, + }); } } diff --git a/src/entrypoints/content.ts b/src/entrypoints/content.ts index 0475f54..50905a8 100644 --- a/src/entrypoints/content.ts +++ b/src/entrypoints/content.ts @@ -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"; @@ -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< diff --git a/src/entrypoints/popup/index.html b/src/entrypoints/popup/index.html new file mode 100644 index 0000000..66017c4 --- /dev/null +++ b/src/entrypoints/popup/index.html @@ -0,0 +1,47 @@ + + + + + + Splits Connect + + + +
+ + Splits Connect +
+
+ +

+ 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 + support@splits.org. +

+
+ + + + + + + diff --git a/src/entrypoints/popup/main.ts b/src/entrypoints/popup/main.ts new file mode 100644 index 0000000..ec10336 --- /dev/null +++ b/src/entrypoints/popup/main.ts @@ -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 { + 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(".avatar-image"); + const fallback = view.querySelector(".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; +} diff --git a/src/entrypoints/popup/style.css b/src/entrypoints/popup/style.css new file mode 100644 index 0000000..8541a39 --- /dev/null +++ b/src/entrypoints/popup/style.css @@ -0,0 +1,125 @@ +:root { + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + +body { + width: 320px; + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; + font-size: 13px; + line-height: 1.5; + color: #222222; + background: #ffffff; +} + +.header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid #ececec; +} + +.header-logo { + width: 20px; + height: 20px; +} + +.header-title { + font-size: 14px; + font-weight: 600; +} + +.content { + padding: 16px; +} + +.footer { + padding: 12px 16px; + border-top: 1px solid #ececec; +} + +.footer a, +.content a { + color: #222222; + font-weight: 500; + text-decoration: underline; + text-underline-offset: 2px; +} + +/* Welcome message — always visible */ + +.welcome-text { + margin: 0; + color: #555555; +} + +/* Signed-in card */ + +.session { + margin-bottom: 14px; + padding-bottom: 14px; + border-bottom: 1px solid #ececec; +} + +.session-label { + margin: 0 0 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: #999999; +} + +.user { + display: flex; + align-items: center; + gap: 12px; +} + +.avatar { + width: 40px; + height: 40px; + flex-shrink: 0; + border-radius: 50%; + object-fit: cover; + background: #f0f0f0; +} + +.avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + color: #555555; +} + +.user-details { + min-width: 0; +} + +.user-name { + font-size: 14px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-email { + color: #777777; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/utils/session-info.ts b/src/utils/session-info.ts new file mode 100644 index 0000000..04e05f3 --- /dev/null +++ b/src/utils/session-info.ts @@ -0,0 +1,80 @@ +// MUST match the message sent by 0xSplits/splits-teams → +// utils/splitsConnectExtension.ts. The Teams app posts this message to its +// own window; the content script relays it to the background, which stores +// the sanitized result for the popup. +export const SESSION_INFO_STORAGE_KEY = "splits:session-info"; +export const SESSION_INFO_MESSAGE_TYPE = "splits-connect:setSessionInfo"; + +// The popup treats stored info older than this as signed out. The Teams app +// re-pushes on every load, so the only sessions that age out are ones that +// ended while no Teams tab was open (expiry, remote logout, cleared cookies) +// — without this, those would display user details indefinitely. +export const SESSION_INFO_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +const MAX_FIELD_LENGTH = 256; + +export type SessionInfo = { + user: { + name?: string; + email?: string; + avatarUrl?: string; + }; + updatedAt: number; +}; + +export function isSessionInfoFresh( + sessionInfo: SessionInfo, + now = Date.now() +) { + return now - sessionInfo.updatedAt <= SESSION_INFO_MAX_AGE_MS; +} + +export function isSessionInfoMessage(message: unknown) { + return ( + typeof message === "object" && + message !== null && + (message as { type?: string }).type === SESSION_INFO_MESSAGE_TYPE + ); +} + +// Returns null when the payload has no usable user — callers treat that as +// signed out and clear the stored value. +export function sanitizeSessionInfo(input: unknown): SessionInfo | null { + if (typeof input !== "object" || input === null) return null; + const user = sanitizeUser((input as { user?: unknown }).user); + if (!user) return null; + return { updatedAt: Date.now(), user }; +} + +function sanitizeUser(input: unknown): SessionInfo["user"] | null { + if (typeof input !== "object" || input === null) return null; + const raw = input as { name?: unknown; email?: unknown; avatarUrl?: unknown }; + const name = sanitizeText(raw.name); + const email = sanitizeText(raw.email); + const avatarUrl = sanitizeHttpUrl(raw.avatarUrl); + if (!name && !email) return null; + return { + ...(name ? { name } : {}), + ...(email ? { email } : {}), + ...(avatarUrl ? { avatarUrl } : {}), + }; +} + +function sanitizeText(input: unknown) { + if (typeof input !== "string") return null; + const trimmed = input.trim(); + if (!trimmed) return null; + return trimmed.slice(0, MAX_FIELD_LENGTH); +} + +function sanitizeHttpUrl(input: unknown) { + const text = sanitizeText(input); + if (!text) return null; + try { + const url = new URL(text); + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + return url.toString(); + } catch { + return null; + } +}