Skip to content
Merged
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
2 changes: 1 addition & 1 deletion manifest/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
}
],
"options_ui": {
"page": "./options/index.html",
"page": "./popup/index.html?view=settings",
"browser_style": false
},
"content_security_policy": {
Expand Down
22 changes: 19 additions & 3 deletions src/background/connectionState.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { action, notifications, runtime, tabs } from "webextension-polyfill";

import { loadSettings } from "#/settings";
import { getEndpoint, loadSettings } from "#/settings";

type ConnectionState = "connected" | "disconnected" | "unknown";

Expand All @@ -15,6 +15,22 @@ let hasShownNotification = false;

const NOTIFICATION_ID = "ethui-connection-status";

export function resetConnectionState() {
globalConnectionState = "unknown";
hasShownNotification = false;
updateBadge();

// Notify popup to re-check connection
runtime
.sendMessage({
type: "connection-state",
state: "unknown",
})
.catch(() => {
// Popup may not be open, ignore error
});
}

export function setConnectionState(state: ConnectionState) {
const previousState = globalConnectionState;
globalConnectionState = state;
Expand Down Expand Up @@ -72,7 +88,7 @@ function showDisconnectedNotification() {

async function checkConnection(): Promise<ConnectionState> {
const settings = await loadSettings();
const endpoint = settings.endpoint;
const endpoint = getEndpoint(settings);

return new Promise((resolve) => {
let resolved = false;
Expand Down Expand Up @@ -108,7 +124,7 @@ async function checkConnection(): Promise<ConnectionState> {

async function fetchWalletInfo(): Promise<WalletInfo | null> {
const settings = await loadSettings();
const endpoint = settings.endpoint;
const endpoint = getEndpoint(settings);

return new Promise((resolve) => {
const ws = new WebSocket(endpoint);
Expand Down
101 changes: 85 additions & 16 deletions src/background/index.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,38 @@
import log from "loglevel";
import { type Runtime, runtime } from "webextension-polyfill";
import { ArrayQueue, ConstantBackoff, WebsocketBuilder } from "websocket-ts";

import { defaultSettings, loadSettings, type Settings } from "#/settings";
import { type Runtime, runtime, storage } from "webextension-polyfill";
import { ArrayQueue, WebsocketBuilder } from "websocket-ts";
import {
defaultSettings,
getEndpoint,
loadSettings,
type Settings,
} from "#/settings";
import {
resetConnectionState,
setConnectionState,
setupConnectionStateListener,
} from "./connectionState";
import { startHeartbeat } from "./heartbeat";
import { updateIcon } from "./utils";

// init on load
(async () => init())();

let settings: Settings = defaultSettings;

const activeConnections: Map<number, { close: () => void }> = new Map();

/**
* Loads the current settings, and listens for incoming connections (from the injected contentscript)
*/
async function init() {
startHeartbeat();
settings = await loadSettings();
log.setLevel(settings.logLevel);
updateIcon(settings.devMode);

setupConnectionStateListener();
setupSettingsChangeListener();

// handle each incoming content script connection
runtime.onConnect.addListener((port: Runtime.Port) => {
Expand All @@ -38,6 +48,43 @@ async function init() {
});
}

/**
* Listen for settings changes and reconnect all active connections when endpoint changes
*/
function setupSettingsChangeListener() {
storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "sync") return;

if (changes.logLevel?.newValue) {
settings.logLevel = changes.logLevel.newValue as Settings["logLevel"];
log.setLevel(settings.logLevel);
log.debug("Log level changed to", settings.logLevel);
}
Comment on lines +58 to +62
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The check for devMode changes using !== undefined (line 64) is more lenient than the check for logLevel which uses truthy checking (line 58). This means setting devMode to false will correctly trigger the handler, but setting logLevel to an empty string (which shouldn't happen but could due to corruption) would not trigger the handler. For consistency and robustness, consider using !== undefined for logLevel as well, or add validation to ensure logLevel is one of the valid values.

Copilot uses AI. Check for mistakes.

if (changes.devMode?.newValue !== undefined) {
settings.devMode = changes.devMode.newValue as boolean;
log.debug(
"Dev mode changed to",
settings.devMode,
"- endpoint:",
getEndpoint(settings),
);

// Update icon color
updateIcon(settings.devMode);

// Reset connection state to trigger fresh check
resetConnectionState();

// Close all active connections - they will reconnect with new endpoint on next message
for (const [tabId, conn] of activeConnections) {
log.debug(`Closing connection for tab ${tabId}`);
conn.close();
}
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

After closing all active connections when devMode changes, the connections remain in the activeConnections map. While they will eventually be removed when port.onDisconnect fires (line 247), there's no guarantee this will happen immediately. If the settings are changed multiple times in quick succession, the loop will try to close connections that have already been closed, potentially calling conn.close() multiple times. While this might not cause errors (since closeWebSocket checks if ws exists), it could lead to unnecessary operations and log spam. Consider either clearing the activeConnections map after closing all connections, or adding a flag to track whether a connection has already been closed.

Suggested change
}
}
// Clear the map to avoid repeatedly closing the same connections if settings change again
activeConnections.clear();

Copilot uses AI. Check for mistakes.
}
});
}

/**
* Sends a message to the devtools in every page.
* Each message will include a timestamp.
Expand Down Expand Up @@ -88,6 +135,19 @@ function setupProviderConnection(port: Runtime.Port) {
let queue: string[] = [];
let ws: ReturnType<typeof WebsocketBuilder.prototype.build> | undefined;
let isConnecting = false;
let intentionalClose = false;

const closeWebSocket = () => {
if (ws) {
intentionalClose = true;
ws.close();
ws = undefined;
}
isConnecting = false;
};

// Register this connection for settings change handling
activeConnections.set(tabId, { close: closeWebSocket });

const initWebSocket = () => {
if (ws || isConnecting) return;
Expand All @@ -111,19 +171,30 @@ function setupProviderConnection(port: Runtime.Port) {
log.debug(`WS connection closed (${url})`);
ws = undefined;
isConnecting = false;

if (intentionalClose) {
// Settings change - don't reconnect, don't set disconnected state
intentionalClose = false;
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The intentionalClose flag could have a race condition. If a WebSocket close event happens naturally (due to network issues) at the same time as settings are changed, the flag might be set to true but then the natural close event could reset it to false (line 177), causing the next settings-triggered close to be treated as an error. Consider using a more robust approach, such as checking a timestamp or having separate handling for settings changes that doesn't rely on a boolean flag that can be affected by concurrent events.

Suggested change
intentionalClose = false;

Copilot uses AI. Check for mistakes.
return;
}

setConnectionState("disconnected");
})
.onReconnect(() => {
log.debug("WS connection reconnected");
setConnectionState("connected");
// Auto-retry after 1 second with current endpoint
setTimeout(() => {
if (!ws && !isConnecting) {
log.debug("Attempting to reconnect...");
initWebSocket();
}
}, 1000);
})
.onError((e) => {
log.error("[WS] error:", e);
isConnecting = false;
setConnectionState("disconnected");
if (!intentionalClose) {
setConnectionState("disconnected");
}
})
.withBuffer(new ArrayQueue())
.withBackoff(new ConstantBackoff(1000))
.onMessage((_ins, event) => {
if (event.data === "ping") {
log.debug("[ws] ping");
Expand Down Expand Up @@ -172,19 +243,17 @@ function setupProviderConnection(port: Runtime.Port) {

port.onDisconnect.addListener(() => {
log.debug("port disconnected");
if (ws) {
ws.close();
ws = undefined;
}
closeWebSocket();
activeConnections.delete(tabId);
queue = [];
});
}

/**
* The URL of the ethui server if given from the settings, with connection metadata being appended as URL params
* The URL of the ethui server based on current settings, with connection metadata being appended as URL params
*/
function endpoint(port: Runtime.Port) {
return `${settings.endpoint}?${connParams(port)}`;
return `${getEndpoint(settings)}?${connParams(port)}`;
}

/**
Expand Down
13 changes: 13 additions & 0 deletions src/background/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { action } from "webextension-polyfill";

export function updateIcon(devMode: boolean) {
const color = devMode ? "purple" : "black";
action.setIcon({
path: {
16: `/icons/ethui-${color}-16.png`,
48: `/icons/ethui-${color}-48.png`,
96: `/icons/ethui-${color}-96.png`,
128: `/icons/ethui-${color}-128.png`,
},
});
}
9 changes: 7 additions & 2 deletions src/options/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@
</div>

<div class="form-section">
<label class="label">Endpoint</label>
<input type="text" id="endpoint" />
<label>
<input type="checkbox" id="dev-mode" />
Developer Mode
</label>
<p style="font-size: 12px; color: #666;">
Connects to port ethui's debug build instead of release. Meant for developers and contributors only.
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

Grammatical error: "Connects to port ethui's debug build" should be "Connects to ethui's debug build on port 9102" or "Connects to port 9102 (ethui's debug build)". The current phrasing is unclear because "port" and "ethui's debug build" are not grammatically connected properly.

Suggested change
Connects to port ethui's debug build instead of release. Meant for developers and contributors only.
Connects to ethui's debug build instead of the release build. Meant for developers and contributors only.

Copilot uses AI. Check for mistakes.
</p>
</div>

<div class="form-section">
Expand Down
12 changes: 6 additions & 6 deletions src/options/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { storage } from "webextension-polyfill";
import { defaultSettings, type Settings } from "#/settings";

const $logLevel = document.getElementById("log-level") as HTMLInputElement;
const $endpoint = document.getElementById("endpoint") as HTMLInputElement;
const $devMode = document.getElementById("dev-mode") as HTMLInputElement;
const $status = document.getElementById("status") as HTMLDivElement;
const $save = document.getElementById("save") as HTMLButtonElement;

Expand All @@ -12,12 +12,12 @@ const saveOptions = () => {
const options: Settings = {
logLevel:
($logLevel.value as Settings["logLevel"]) || defaultSettings.logLevel,
endpoint: $endpoint.value || defaultSettings.endpoint,
devMode: $devMode.checked,
};

storage.sync.set(options).then(() => {
storage.sync.set(options as Record<string, unknown>).then(() => {
// Update status to let user know options were saved.
$status.textContent = "Options saved. Restart browser to take effect";
$status.textContent = "Options saved";
setTimeout(() => {
$status.textContent = "";
}, 750);
Expand All @@ -27,9 +27,9 @@ const saveOptions = () => {
// Restores select box and checkbox state using the preferences
// stored in chrome.storage.
const restoreOptions = () => {
storage.sync.get(defaultSettings).then((items) => {
storage.sync.get(defaultSettings as Record<string, unknown>).then((items) => {
$logLevel.value = items.logLevel as string;
$endpoint.value = items.endpoint as string;
$devMode.checked = items.devMode as boolean;
});
};

Expand Down
15 changes: 14 additions & 1 deletion src/popup/components/ConnectedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@ import { useWalletInfo } from "../hooks/useWalletInfo";
import { formatBalance, getChainName, truncateAddress } from "../utils";
import { Header } from "./Header";

export function ConnectedView() {
interface ConnectedViewProps {
onExpand?: () => void;
onSettings: () => void;
devMode?: boolean;
}

export function ConnectedView({
onExpand,
onSettings,
devMode,
}: ConnectedViewProps) {
const { walletInfo, loading } = useWalletInfo();
const [copied, setCopied] = useState(false);

Expand All @@ -33,6 +43,9 @@ export function ConnectedView() {
</span>
)
}
devMode={devMode}
onExpand={onExpand}
onSettings={onSettings}
/>

{loading ? (
Expand Down
17 changes: 15 additions & 2 deletions src/popup/components/DisconnectedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,28 @@ import { Header } from "./Header";

interface DisconnectedViewProps {
connectionState: ConnectionState;
onExpand?: () => void;
onSettings: () => void;
devMode?: boolean;
}

export function DisconnectedView({ connectionState }: DisconnectedViewProps) {
export function DisconnectedView({
connectionState,
onExpand,
onSettings,
devMode,
}: DisconnectedViewProps) {
const title =
connectionState === "disconnected" ? "Not Connected" : "Checking...";

return (
<div className="p-4">
<Header title={title} />
<Header
title={title}
devMode={devMode}
onExpand={onExpand}
onSettings={onSettings}
/>
{connectionState === "disconnected" && (
<div className="space-y-3">
<Alert variant="destructive">
Expand Down
Loading
Loading