From 90ff669e4e20817357600e8f1d200fa90a59ef3b Mon Sep 17 00:00:00 2001 From: Mike H Date: Thu, 11 Jun 2026 10:59:42 -0700 Subject: [PATCH 1/9] PE-6102 | add extension popup showing session info Clicking the toolbar icon now opens a popup. When session info is available it shows the user's name, avatar, and email plus the currently selected organization and smart account; otherwise it shows a welcome message explaining that no action is needed in the extension. Session info reaches the extension via a new externally-connectable message (splits-connect:setSessionInfo) that the Splits Teams app can send after sign-in, org/account changes, and sign-out. The payload is origin-checked and sanitized before being stored in browser.storage.local, and the popup re-renders live on storage changes. Co-Authored-By: Claude Fable 5 --- src/entrypoints/background.ts | 95 +++++++++++++++------ src/entrypoints/popup/index.html | 25 ++++++ src/entrypoints/popup/main.ts | 119 ++++++++++++++++++++++++++ src/entrypoints/popup/style.css | 141 +++++++++++++++++++++++++++++++ src/utils/session-info.ts | 84 ++++++++++++++++++ 5 files changed, 440 insertions(+), 24 deletions(-) create mode 100644 src/entrypoints/popup/index.html create mode 100644 src/entrypoints/popup/main.ts create mode 100644 src/entrypoints/popup/style.css create mode 100644 src/utils/session-info.ts diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index 2e9c98d..cb26b2b 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,49 @@ 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 +// sends `{ type: "splits-connect:setSessionInfo", sessionInfo }` after sign-in +// or org/account changes, and `sessionInfo: null` on sign-out. +namespace SessionInfoBridge { + export function register() { + browser.runtime.onMessageExternal.addListener(handleExternalMessage); + } + + function handleExternalMessage( + 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/popup/index.html b/src/entrypoints/popup/index.html new file mode 100644 index 0000000..06beb7f --- /dev/null +++ b/src/entrypoints/popup/index.html @@ -0,0 +1,25 @@ + + + + + + Splits Connect + + + +
+ + Splits Connect +
+
+ + + + diff --git a/src/entrypoints/popup/main.ts b/src/entrypoints/popup/main.ts new file mode 100644 index 0000000..060302c --- /dev/null +++ b/src/entrypoints/popup/main.ts @@ -0,0 +1,119 @@ +import { + SESSION_INFO_STORAGE_KEY, + type SessionInfo, +} from "@/utils/session-info"; + +const root = document.getElementById("app"); +if (root) { + void readSessionInfo().then((sessionInfo) => render(root, 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(root, 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; +} + +function render(target: HTMLElement, sessionInfo: SessionInfo | null) { + target.replaceChildren( + sessionInfo ? renderSignedIn(sessionInfo) : renderSignedOut() + ); +} + +function renderSignedOut() { + const container = document.createElement("div"); + + const title = document.createElement("h1"); + title.className = "welcome-title"; + title.textContent = "Welcome to Splits Connect!"; + + const text = document.createElement("p"); + text.className = "welcome-text"; + text.append( + "You do not need to do anything in this extension — when you follow the connect flow on your application, you should see Splits as a wallet option. If you do not, please reach out to " + ); + const support = document.createElement("a"); + support.href = "mailto:support@splits.org"; + support.textContent = "support@splits.org"; + text.append(support, "."); + + container.append(title, text); + return container; +} + +function renderSignedIn(sessionInfo: SessionInfo) { + const container = document.createElement("div"); + + const user = document.createElement("div"); + user.className = "user"; + + const details = document.createElement("div"); + details.className = "user-details"; + const name = document.createElement("div"); + name.className = "user-name"; + name.textContent = sessionInfo.user.name ?? sessionInfo.user.email ?? ""; + details.append(name); + if (sessionInfo.user.name && sessionInfo.user.email) { + const email = document.createElement("div"); + email.className = "user-email"; + email.textContent = sessionInfo.user.email; + details.append(email); + } + + user.append(renderAvatar(sessionInfo.user), details); + container.append(user); + + const rows: Array<[string, string]> = []; + if (sessionInfo.org) rows.push(["Organization", sessionInfo.org.name]); + if (sessionInfo.smartAccount) + rows.push(["Smart account", sessionInfo.smartAccount.name]); + if (rows.length > 0) { + const context = document.createElement("div"); + context.className = "context"; + for (const [label, value] of rows) { + const row = document.createElement("div"); + row.className = "context-row"; + const labelEl = document.createElement("span"); + labelEl.className = "context-label"; + labelEl.textContent = label; + const valueEl = document.createElement("span"); + valueEl.className = "context-value"; + valueEl.textContent = value; + row.append(labelEl, valueEl); + context.append(row); + } + container.append(context); + } + + return container; +} + +function renderAvatar(user: SessionInfo["user"]) { + if (user.avatarUrl) { + const image = document.createElement("img"); + image.className = "avatar"; + image.src = user.avatarUrl; + image.alt = ""; + image.addEventListener( + "error", + () => image.replaceWith(renderAvatarFallback(user)), + { once: true } + ); + return image; + } + return renderAvatarFallback(user); +} + +function renderAvatarFallback(user: SessionInfo["user"]) { + const fallback = document.createElement("div"); + fallback.className = "avatar avatar-fallback"; + const source = user.name ?? user.email ?? ""; + fallback.textContent = source.slice(0, 1).toUpperCase(); + return fallback; +} diff --git a/src/entrypoints/popup/style.css b/src/entrypoints/popup/style.css new file mode 100644 index 0000000..4af458a --- /dev/null +++ b/src/entrypoints/popup/style.css @@ -0,0 +1,141 @@ +:root { + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +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; +} + +/* Signed-out state */ + +.welcome-title { + margin: 0 0 8px; + font-size: 15px; + font-weight: 600; +} + +.welcome-text { + margin: 0; + color: #555555; +} + +/* Signed-in state */ + +.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; +} + +.context { + margin-top: 14px; + padding: 10px 12px; + border: 1px solid #ececec; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.context-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.context-label { + flex-shrink: 0; + color: #777777; +} + +.context-value { + font-weight: 500; + 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..0363334 --- /dev/null +++ b/src/utils/session-info.ts @@ -0,0 +1,84 @@ +export const SESSION_INFO_STORAGE_KEY = "splits:session-info"; +export const SESSION_INFO_MESSAGE_TYPE = "splits-connect:setSessionInfo"; + +const MAX_FIELD_LENGTH = 256; + +export type SessionInfo = { + user: { + name?: string; + email?: string; + avatarUrl?: string; + }; + org: { name: string } | null; + smartAccount: { name: string } | null; + updatedAt: number; +}; + +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 raw = input as { + user?: unknown; + org?: unknown; + smartAccount?: unknown; + }; + + const user = sanitizeUser(raw.user); + if (!user) return null; + + return { + org: sanitizeNamed(raw.org), + smartAccount: sanitizeNamed(raw.smartAccount), + 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 sanitizeNamed(input: unknown): { name: string } | null { + if (typeof input !== "object" || input === null) return null; + const name = sanitizeText((input as { name?: unknown }).name); + if (!name) return null; + return { name }; +} + +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; + } +} From fe5b42b91ce25c78fa1c3e105963656dded11cb9 Mon Sep 17 00:00:00 2001 From: Mike H Date: Thu, 11 Jun 2026 14:34:03 -0700 Subject: [PATCH 2/9] PE-6102 | relay session info via content script instead of externally_connectable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Teams app has no way to know the extension ID at connect time (it only appears in large-RPC placeholders), so the page cannot use chrome.runtime.sendMessage. Instead the app posts the session info to its own window and the content script — attached only on the Teams origin — relays it to the background over internal messaging. The background still origin-checks the sender and sanitizes the payload before storing. Co-Authored-By: Claude Fable 5 --- src/entrypoints/background.ts | 10 ++++++---- src/entrypoints/content.ts | 26 ++++++++++++++++++++++++++ src/utils/session-info.ts | 4 ++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index cb26b2b..124752f 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -101,14 +101,16 @@ namespace RpcStorageBridge { } // Lets the Splits Teams app keep the popup's session display in sync. The app -// sends `{ type: "splits-connect:setSessionInfo", sessionInfo }` after sign-in -// or org/account changes, and `sessionInfo: null` on sign-out. +// posts `{ type: "splits-connect:setSessionInfo", sessionInfo }` to its own +// window after the connect flow completes, and `sessionInfo: null` on +// sign-out; the content script relays it here. Sender pages are re-checked +// against the Teams origin before anything is stored. namespace SessionInfoBridge { export function register() { - browser.runtime.onMessageExternal.addListener(handleExternalMessage); + browser.runtime.onMessage.addListener(handleMessage); } - function handleExternalMessage( + function handleMessage( message: unknown, sender: MessageSender, sendResponse: (response: unknown) => void 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/utils/session-info.ts b/src/utils/session-info.ts index 0363334..acfda02 100644 --- a/src/utils/session-info.ts +++ b/src/utils/session-info.ts @@ -1,3 +1,7 @@ +// 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"; From 9a81ee8bc928a25c9d3f83edf3e6d41412fdd5fe Mon Sep 17 00:00:00 2001 From: Mike H Date: Thu, 11 Jun 2026 15:45:05 -0700 Subject: [PATCH 3/9] PE-6102 | show only the user in the popup; move markup into templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Org/account is per-dapp state (a user can be connected to two apps with different orgs/accounts at once), so a single global popup cannot display it honestly — drop it and show only the user, which is genuinely global. Popup markup now lives in