Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d6df6b5
feat: harden sidebar mode with Firefox support, security fixes, and r…
piyalbasu Apr 1, 2026
b37b32b
security: add interstitial gate and route allowlist for sidebar signi…
piyalbasu Apr 1, 2026
dd8c834
fix: add missing Portuguese translations for sidebar mode strings
piyalbasu Apr 1, 2026
d9fe266
fix: remove Firefox "open sidebar by default" — not supported by plat…
piyalbasu Apr 1, 2026
5e7d27f
fix: update loadSaveSettings tests to mock booleans instead of strings
piyalbasu Apr 1, 2026
2e1b9c3
Apply suggestions from code review
piyalbasu Apr 1, 2026
fa12d30
fix: use named onRemoved handler checking popup.id and self-removing …
Copilot Apr 1, 2026
ab9b3be
redesign ConfirmSidebarRequest to match updated Figma spec
piyalbasu Apr 2, 2026
780d852
Update extension/src/popup/locales/pt/translation.json
piyalbasu Apr 2, 2026
69d1a8f
Update extension/src/popup/views/ConfirmSidebarRequest/index.tsx
piyalbasu Apr 2, 2026
26200d0
fix: track sidebar UUIDs separately and guard port/windowId clear on …
Copilot Apr 2, 2026
58ca5ff
fix: address code review issues for sidebar signing flow
piyalbasu Apr 2, 2026
3aeefb5
fix: defer sidebar disconnect cleanup to avoid rejecting during page …
piyalbasu Apr 2, 2026
bfea660
Update extension/src/popup/constants/metricsNames.ts
piyalbasu Apr 2, 2026
c6e0f88
fix: add REJECT_SIGNING_REQUEST handler to clean all queues on Confir…
Copilot Apr 2, 2026
1ba4ef0
refactor: unify sidebarQueueUuids lifecycle with activeQueueUuids
piyalbasu Apr 2, 2026
95d251f
test: add coverage for sidebar disconnect cleanup and MARK_QUEUE_ACTIVE
piyalbasu Apr 2, 2026
161e8bc
refactor: move sidebar port helpers to background/helpers/sidebarPort
piyalbasu Apr 2, 2026
b409632
fix: rejectSigningRequest returns consistent empty object instead of …
Copilot Apr 2, 2026
9f46630
docs: add sidebar mode implementation spec
piyalbasu Apr 3, 2026
f5dc8c8
fix: track sidebar-routed UUIDs at routing time and guard disconnect …
piyalbasu Apr 3, 2026
ed555b1
docs: update sidebar spec to reflect queue tracking changes
piyalbasu Apr 3, 2026
25bf3cb
feat: add sidebarMode property to signing view metrics
piyalbasu Apr 3, 2026
7357a8a
fix: harden sidebar message handling per security review
piyalbasu Apr 3, 2026
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
16 changes: 16 additions & 0 deletions @shared/api/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2407,3 +2407,19 @@ export const markQueueActive = async ({
console.error(e);
}
};

export const rejectSigningRequest = async ({
uuid,
}: {
uuid: string;
}): Promise<void> => {
try {
await sendMessageToBackground({
activePublicKey: null,
uuid,
type: SERVICE_TYPES.REJECT_SIGNING_REQUEST,
});
} catch (e) {
console.error(e);
}
};
19 changes: 7 additions & 12 deletions @shared/api/types/message-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,20 +445,16 @@ export interface MarkQueueActiveMessage extends BaseMessage {
isActive: boolean;
}

export interface SidebarRegisterMessage extends BaseMessage {
type: SERVICE_TYPES.SIDEBAR_REGISTER;
windowId: number;
}

export interface SidebarUnregisterMessage extends BaseMessage {
type: SERVICE_TYPES.SIDEBAR_UNREGISTER;
}

export interface OpenSidebarMessage extends BaseMessage {
type: SERVICE_TYPES.OPEN_SIDEBAR;
windowId: number;
}

export interface RejectSigningRequestMessage extends BaseMessage {
type: SERVICE_TYPES.REJECT_SIGNING_REQUEST;
uuid: string;
}

export type ServiceMessageRequest =
| FundAccountMessage
| CreateAccountMessage
Expand Down Expand Up @@ -524,6 +520,5 @@ export type ServiceMessageRequest =
| ChangeCollectibleVisibilityMessage
| GetHiddenCollectiblesMessage
| MarkQueueActiveMessage
| SidebarRegisterMessage
| SidebarUnregisterMessage
| OpenSidebarMessage;
| OpenSidebarMessage
| RejectSigningRequestMessage;
5 changes: 3 additions & 2 deletions @shared/constants/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ export enum SERVICE_TYPES {
CHANGE_COLLECTIBLE_VISIBILITY = "CHANGE_COLLECTIBLE_VISIBILITY",
GET_HIDDEN_COLLECTIBLES = "GET_HIDDEN_COLLECTIBLES",
MARK_QUEUE_ACTIVE = "MARK_QUEUE_ACTIVE",
SIDEBAR_REGISTER = "SIDEBAR_REGISTER",
SIDEBAR_UNREGISTER = "SIDEBAR_UNREGISTER",
OPEN_SIDEBAR = "OPEN_SIDEBAR",
REJECT_SIGNING_REQUEST = "REJECT_SIGNING_REQUEST",
}

export const SIDEBAR_NAVIGATE = "SIDEBAR_NAVIGATE";

export enum EXTERNAL_SERVICE_TYPES {
REQUEST_ACCESS = "REQUEST_ACCESS",
REQUEST_PUBLIC_KEY = "REQUEST_PUBLIC_KEY",
Expand Down
7 changes: 7 additions & 0 deletions extension/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# CLAUDE.md

This file provides guidance to Claude Code when working with the Freighter browser extension.

## Specs

Implementation specs for complex features live in `extension/specs/`. Check there for architectural context before deep codebase exploration.
8 changes: 8 additions & 0 deletions extension/public/static/manifest/v3.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,13 @@
"side_panel": {
"default_path": "index.html?mode=sidebar"
},
"sidebar_action": {
"default_panel": "index.html?mode=sidebar",
"default_icon": {
"16": "images/icon16.png",
"32": "images/icon32.png"
},
"open_at_install": false
},
"manifest_version": 3
}
153 changes: 153 additions & 0 deletions extension/specs/SIDEBAR_MODE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# Sidebar Mode Implementation Spec
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.

I added this spec because there ended up being some "gotchas" with this implementation.

First, there's the difference in browser implementation

Second, the inability to add e2e tests with Playwright

I asked Claude how we can help future agents have an easier time making changes and it guided me towards this solution: this type of spec that is linked in CLAUDE.MD should help make any architectural decisions and prevent agents from going down dead-ends we've already investigated in the future

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.

It's also nice to have a human readable description of what we're doing here for future human devs! 😄


## Overview

Freighter's sidebar mode allows the extension to render in the browser's side panel instead of a popup window. When active, signing requests navigate within the sidebar rather than opening separate popup windows.

## How Sidebar Mode Is Detected

**File:** `src/popup/helpers/isSidebarMode.ts`

```ts
export const isSidebarMode = () =>
/^(chrome|moz)-extension:$/.test(window.location.protocol) &&
new URLSearchParams(window.location.search).get("mode") === "sidebar";
```

The URL `index.html?mode=sidebar` is set in the manifest and passed by `openSidebar()`.

## How Sidebar Mode Is Activated

**UI entry point:** `src/popup/components/account/AccountHeader/index.tsx` (lines 185-200)

The account options dropdown (test ID: `account-options-dropdown`) shows a "Sidebar mode" menu item, conditionally rendered only when the browser supports it (`chrome.sidePanel.open` or `browser.sidebarAction.open`).

**Activation function:** `src/popup/helpers/navigate.ts` — `openSidebar()`

- **Chrome:** Calls `chrome.sidePanel.setOptions({ path: "index.html?mode=sidebar" })` then `chrome.sidePanel.open({ windowId })`.
- **Firefox:** Calls `browser.sidebarAction.open()`.
- In both cases, `window.close()` is called afterward to close the popup.

**Manifest config:**

```json
"permissions": ["storage", "alarms", "sidePanel"],
"side_panel": { "default_path": "index.html?mode=sidebar" },
"sidebar_action": {
"default_panel": "index.html?mode=sidebar",
"open_at_install": false
}
```

## Architecture: Signing Flow in Sidebar Mode

### 1. Sidebar connects to background

When `isSidebarMode()` returns true, `Router.tsx` mounts `<SidebarSigningListener />`.

**File:** `src/popup/components/SidebarSigningListener/index.tsx`

On mount, the component:

1. Opens a long-lived port to the background: `browser.runtime.connect({ name: "sidebar" })`
2. Sends the window ID to the background: `port.postMessage({ windowId: win.id })`
3. Overrides `window.close()` to navigate to the account route instead of closing the panel
4. Listens for `SIDEBAR_NAVIGATE` messages on the port

### 2. Background registers the sidebar

**File:** `src/background/index.ts` — `initSidebarConnectionListener()` (line 65)

When the background receives a port connection named "sidebar":

1. **Validates the sender** — rejects content scripts (`!port.sender?.tab` check ensures only extension pages connect)
2. Stores the port via `setSidebarPort(port)` (in `src/background/helpers/sidebarPort.ts`)
3. Stores the window ID via `setSidebarWindowId()` when the sidebar sends its first message
4. On disconnect, clears state and schedules a 500ms deferred cleanup (to allow quick sidebar reloads without dropping requests)

### 3. Signing request routing

**File:** `src/background/messageListener/freighterApiMessageListener.ts` — `openSigningWindow()` (line 76)

When a dApp triggers a signing request:

1. Checks `getSidebarWindowId()` — if not null, sidebar is active
2. **Sidebar path:** Sends `{ type: SIDEBAR_NAVIGATE, route: hashRoute }` over the port, then calls `chrome.sidePanel.open()` to focus it. Returns `null` (no popup created).
3. **Popup fallback:** If no sidebar, creates a standalone popup window via `browser.windows.create()`

### 4. Sidebar handles navigation

Back in `SidebarSigningListener`, the port handler receives the `SIDEBAR_NAVIGATE` message:

1. **Route validation:** Only allows navigation to known signing routes (`signTransaction`, `signAuthEntry`, `signMessage`, `grantAccess`, `addToken`, `reviewAuthorization`)
2. **Concurrent request handling:** If the user is already on a signing route, navigates to `ConfirmSidebarRequest` interstitial instead of silently swapping the screen
3. **Otherwise:** Navigates directly to the signing route via React Router

### 5. Concurrent request interstitial

**File:** `src/popup/views/ConfirmSidebarRequest/index.tsx`

Shows "New Signing Request" with two options:

- **Reject:** Extracts the UUID from the pending route's query string, calls `rejectSigningRequest()` to clean up all queues, navigates to account
- **Continue to review:** Navigates to the new signing route (passed via `?next=` query param, validated against open redirect)

## Queue Tracking

**File:** `src/background/helpers/queueCleanup.ts`

`sidebarQueueUuids: Set<string>` tracks which signing requests were routed to the sidebar. UUIDs are added at routing time in `openSigningWindow()` (in `freighterApiMessageListener.ts`) when the sidebar path is chosen — not at view mount time. This ensures requests behind the `ConfirmSidebarRequest` interstitial are tracked before the signing view mounts.

When the sidebar disconnects (and doesn't reconnect within 500ms), only these UUIDs are rejected — standalone popup requests have their own `onWindowRemoved` cleanup. The disconnect cleanup only fires when the disconnecting port is the currently active sidebar port; stale port disconnects are ignored.

## Key Constants

| Constant | Value | Location |
| ------------------------------ | ---------------------------- | ---------------------------------- |
| `SIDEBAR_PORT_NAME` | `"sidebar"` | `SidebarSigningListener/index.tsx` |
| `SIDEBAR_NAVIGATE` | `"SIDEBAR_NAVIGATE"` | `@shared/constants/services.ts` |
| `ROUTES.confirmSidebarRequest` | `"/confirm-sidebar-request"` | `popup/constants/routes.ts` |

## "Open Sidebar Mode by Default" — Chrome Only

The Preferences page (`src/popup/views/Preferences/index.tsx`, line 185) has a toggle labeled "Open sidebar mode by default" that makes the extension icon click open the sidebar instead of the popup. This toggle is **only shown on Chrome** — it's gated behind `typeof globalThis.chrome?.sidePanel?.open === "function"`.

**Why it's hidden on Firefox:**

The toggle works by calling `chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true })` (in `src/background/index.ts` — `initSidebarBehavior()`, line 217, and `src/background/messageListener/handlers/saveSettings.ts`, line 44). This is a Chrome-only API that tells the browser to open the side panel instead of the popup when the user clicks the extension's toolbar icon.

Firefox has no equivalent API. `browser.sidebarAction` can open/close the sidebar programmatically, but:

1. `sidebarAction.open()` requires a synchronous user gesture (it cannot be called from a background script in response to an action click)
2. There is no `setPanelBehavior` equivalent to redirect action clicks to the sidebar

The setting is persisted in `localStorage` under `IS_OPEN_SIDEBAR_BY_DEFAULT_ID` (`"isOpenSidebarByDefault"`). On Chrome, `initSidebarBehavior()` reads this on startup and applies it via `setPanelBehavior`. On Firefox, the function is a no-op — the comment at line 228 documents this explicitly.

Firefox users can still open sidebar mode manually via the "Sidebar mode" menu item in the account dropdown, or via the browser's native sidebar toggle.

## E2E Testing Limitations

**Playwright cannot test true sidebar mode.** The background's `initSidebarConnectionListener` rejects port connections from pages that have `port.sender.tab` set. In Playwright, the extension page runs in a regular browser tab (not a real side panel), so the port is always rejected. This means:

- `getSidebarWindowId()` is always null in Playwright
- `openSigningWindow()` always falls back to creating popup windows
- The `SidebarSigningListener` component mounts but its port connection is rejected

To properly E2E test sidebar signing flow, Playwright would need to support Chrome's Side Panel API so the extension page runs without a tab context.

**What was tried and why it failed:** We attempted to create sidebar-specific E2E tests by navigating to `?mode=sidebar` in Playwright. While `isSidebarMode()` returns true on the frontend, the background rejects the `SidebarSigningListener` port connection because the page runs in a tab (`port.sender.tab` is set). All signing requests fall back to popup windows, making the tests functionally identical to the existing popup tests. True sidebar E2E tests require either Playwright support for Chrome's Side Panel API, or removing the `!port.sender.tab` guard in the background (which would weaken security).

## File Reference

| File | Role |
| --------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `src/popup/helpers/isSidebarMode.ts` | Detects sidebar mode via URL param |
| `src/popup/helpers/navigate.ts` | `openSidebar()` — opens side panel |
| `src/popup/components/SidebarSigningListener/index.tsx` | Port connection, navigation listener, window.close override |
| `src/popup/views/ConfirmSidebarRequest/index.tsx` | Concurrent request interstitial |
| `src/popup/Router.tsx` | Conditionally mounts SidebarSigningListener, defines confirmSidebarRequest route |
| `src/popup/components/account/AccountHeader/index.tsx` | "Sidebar mode" dropdown menu item |
| `src/background/index.ts` | `initSidebarConnectionListener()` — port validation, state management, cleanup |
| `src/background/helpers/sidebarPort.ts` | Global sidebar port state |
| `src/background/helpers/queueCleanup.ts` | `sidebarQueueUuids` set |
| `src/background/messageListener/freighterApiMessageListener.ts` | `openSigningWindow()` — routes to sidebar or popup |
5 changes: 5 additions & 0 deletions extension/src/background/helpers/queueCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export const CLEANUP_INTERVAL_MS = 60 * 1000;
// In MV3, this resets when the service worker restarts, which also resets the queues.
export const activeQueueUuids: Set<string> = new Set();

// Set of UUIDs for signing requests that are being handled by the sidebar
// (as opposed to a standalone popup window). Only these should be rejected
// when the sidebar disconnects.
export const sidebarQueueUuids: Set<string> = new Set();

/**
* Removes expired items from a queue based on their createdAt timestamp.
* Items older than the TTL are removed. Items without createdAt are also removed
Expand Down
12 changes: 12 additions & 0 deletions extension/src/background/helpers/sidebarPort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import browser from "webextension-polyfill";

// Long-lived port to the sidebar, set by initSidebarConnectionListener
let sidebarPort: browser.Runtime.Port | null = null;

export const setSidebarPort = (port: browser.Runtime.Port) => {
sidebarPort = port;
};
export const clearSidebarPort = () => {
sidebarPort = null;
};
export const getSidebarPort = () => sidebarPort;
Loading
Loading