Skip to content
Open
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
198 changes: 198 additions & 0 deletions src/background/appConnection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
/**
* Main ethui app WebSocket connection handler.
*
* This module handles the primary connection to the ethui desktop application.
* When the ethui app is running, all Ethereum JSON-RPC requests are proxied
* through this connection.
*/

import log from "loglevel";
import type { Runtime } from "webextension-polyfill";
import { ArrayQueue, WebsocketBuilder } from "websocket-ts";
import { getEndpoint, type Settings } from "#/settings";
import { setConnectionState } from "./connectionState";

type MessageHandler = (data: unknown) => void;
type DisconnectHandler = () => void;

export interface AppConnectionResult {
send: (msg: string) => void;
close: () => void;
isConnected: () => boolean;
isConnecting: () => boolean;
}

/**
* Creates a connection to the ethui desktop app.
*
* @param settings - Current extension settings
* @param port - The runtime port for connection metadata
* @param onMessage - Handler for incoming messages from the ethui app
* @param onDisconnect - Handler called when connection is lost (for fallback switching)
* @returns Connection control object
*/
export function createAppConnection(
settings: Settings,
port: Runtime.Port,
onMessage: MessageHandler,
onDisconnect: DisconnectHandler,
): AppConnectionResult {
let ws: ReturnType<typeof WebsocketBuilder.prototype.build> | undefined;
let isConnectingFlag = false;
let intentionalClose = false;
let queue: string[] = [];

const tab = port.sender?.tab;
const url = tab?.url ?? "unknown";

const endpoint = buildEndpoint(settings, port);

const close = () => {
if (ws) {
intentionalClose = true;
ws.close();
ws = undefined;
}
isConnectingFlag = false;
queue = [];
};

const initWebSocket = () => {
if (ws || isConnectingFlag) return;

isConnectingFlag = true;
log.debug(`[AppConnection] Initializing WS connection for ${url}`);

ws = new WebsocketBuilder(endpoint)
.onOpen(() => {
log.debug(`[AppConnection] WS connection opened (${url})`);
isConnectingFlag = false;
setConnectionState("connected", "app");

// Flush queue
while (queue.length > 0) {
const msg = queue.shift()!;
ws!.send(msg);
}
})
.onClose(() => {
log.debug(`[AppConnection] WS connection closed (${url})`);
ws = undefined;
isConnectingFlag = false;

if (intentionalClose) {
intentionalClose = false;
return;
}

// Notify parent about disconnection for fallback handling
onDisconnect();
})
.onError((e) => {
log.error("[AppConnection] WS error:", e);
isConnectingFlag = false;
if (!intentionalClose) {
// Don't set disconnected here - let onDisconnect handler decide
onDisconnect();
}
})
.withBuffer(new ArrayQueue())
.onMessage((_ins, event) => {
if (event.data === "ping") {
log.debug("[AppConnection] ping");
ws!.send("pong");
return;
}
log.debug("[AppConnection] message", event.data);
onMessage(JSON.parse(event.data));
})
.build();
};

const send = (msg: string) => {
if (!ws && !isConnectingFlag) {
initWebSocket();
}

if (!ws || isConnectingFlag) {
queue.push(msg);
} else {
ws.send(msg);
}
};

return {
send,
close,
isConnected: () => !!ws && !isConnectingFlag,
isConnecting: () => isConnectingFlag,
};
}

/**
* Checks if the ethui app is available at the given endpoint.
*
* @param settings - Current extension settings
* @returns Promise that resolves to true if app is available
*/
export function checkAppAvailable(settings: Settings): Promise<boolean> {
const endpoint = getEndpoint(settings);

return new Promise((resolve) => {
const ws = new WebSocket(endpoint);
const timeout = setTimeout(() => {
ws.close();
resolve(false);
}, 2000);

ws.onopen = () => {
clearTimeout(timeout);
ws.close();
resolve(true);
};

ws.onerror = () => {
clearTimeout(timeout);
ws.close();
resolve(false);
};
});
}

/**
* Builds the WebSocket endpoint URL with connection metadata.
*/
function buildEndpoint(settings: Settings, port: Runtime.Port): string {
const base = getEndpoint(settings);
const params = buildConnParams(port);
return `${base}?${params}`;
}

/**
* URL-encoded connection info for the ethui server.
*/
function buildConnParams(port: Runtime.Port): string {
const sender = port.sender;
const tab = sender?.tab;

const params: Record<string, string | undefined> = {
origin: (port.sender as unknown as { origin: string }).origin,
url: tab?.url,
title: tab?.title,
};

return encodeUrlParams(params);
}

/**
* URL-encode a set of params.
*/
function encodeUrlParams(p: Record<string, string | undefined>): string {
const filtered: Record<string, string> = Object.fromEntries(
Object.entries(p).filter(([, v]) => v !== undefined),
) as Record<string, string>;

return Object.entries(filtered)
.map((kv) => kv.map(encodeURIComponent).join("="))
.join("&");
}
40 changes: 35 additions & 5 deletions src/background/connectionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { action, notifications, runtime, tabs } from "webextension-polyfill";
import { getEndpoint, loadSettings } from "#/settings";

type ConnectionState = "connected" | "disconnected" | "unknown";
type ConnectionSource = "app" | "fallback" | null;

interface WalletInfo {
accounts: string[];
Expand All @@ -11,12 +12,14 @@ interface WalletInfo {
}

let globalConnectionState: ConnectionState = "unknown";
let globalConnectionSource: ConnectionSource = null;
let hasShownNotification = false;

const NOTIFICATION_ID = "ethui-connection-status";

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

Expand All @@ -25,21 +28,32 @@ export function resetConnectionState() {
.sendMessage({
type: "connection-state",
state: "unknown",
source: null,
})
.catch(() => {
// Popup may not be open, ignore error
});
}

export function setConnectionState(state: ConnectionState) {
export function setConnectionState(
state: ConnectionState,
source: ConnectionSource = null,
) {
const previousState = globalConnectionState;
globalConnectionState = state;

if (state === "connected") {
globalConnectionSource = source;
} else if (state === "disconnected") {
globalConnectionSource = null;
}

// Broadcast state change to any open popups
runtime
.sendMessage({
type: "connection-state",
state: globalConnectionState,
source: globalConnectionSource,
})
.catch(() => {
// Popup may not be open, ignore error
Expand Down Expand Up @@ -67,6 +81,13 @@ function updateBadge() {
if (globalConnectionState === "disconnected") {
action.setBadgeText({ text: "!" });
action.setBadgeBackgroundColor({ color: "#ef4444" });
} else if (
globalConnectionState === "connected" &&
globalConnectionSource === "fallback"
) {
// Show indicator when using fallback
action.setBadgeText({ text: "F" });
action.setBadgeBackgroundColor({ color: "#f59e0b" }); // amber/warning color
} else {
action.setBadgeText({ text: "" });
}
Expand Down Expand Up @@ -96,7 +117,7 @@ async function checkConnection(): Promise<ConnectionState> {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
setConnectionState(state);
setConnectionState(state, state === "connected" ? "app" : null);
resolve(state);
};

Expand Down Expand Up @@ -124,7 +145,7 @@ async function checkConnection(): Promise<ConnectionState> {

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

return new Promise((resolve) => {
const ws = new WebSocket(endpoint);
Expand Down Expand Up @@ -233,12 +254,17 @@ export function setupConnectionStateListener() {
// If state is unknown, check connection before responding
if (globalConnectionState === "unknown") {
checkConnection().then((state) => {
sendResponse({ type: "connection-state", state });
sendResponse({
type: "connection-state",
state,
source: globalConnectionSource,
});
});
} else {
sendResponse({
type: "connection-state",
state: globalConnectionState,
source: globalConnectionSource,
});
}
return true;
Expand All @@ -253,7 +279,11 @@ export function setupConnectionStateListener() {

if (msg.type === "check-connection") {
checkConnection().then((state) => {
sendResponse({ type: "connection-state", state });
sendResponse({
type: "connection-state",
state,
source: globalConnectionSource,
});
});
return true;
}
Expand Down
Loading