diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 00000000..0c83ac4e --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/backend-mock/apps.js b/backend-mock/apps.js index 2fde99c1..b8902c93 100644 --- a/backend-mock/apps.js +++ b/backend-mock/apps.js @@ -1,11 +1,12 @@ const express = require("express"); const router = express.Router(); const util = require("./sse/util"); +const index = require("./index"); router.post("/install/:id", (req, res) => { console.info("call to /api/apps/install for app", req.params.id); // send information that btc-pay is currently installing - util.sendSSE("install", { + util.sendData(index.ws, "install", { id: "rtl", mode: "on", result: "running", @@ -44,7 +45,7 @@ const installApp = () => { console.info("call to installApp"); // inform Frontend that app finished installing - util.sendSSE("install", { + util.sendData(index.ws, "install", { id: "rtl", mode: "on", result: "win", @@ -53,7 +54,7 @@ const installApp = () => { details: "OK", }); - util.sendSSE("installed_app_status", [ + util.sendData(index.ws, "installed_app_status", [ { id: "lnbits", installed: false, status: "offline", error: "" }, { id: "thunderhub", installed: false, status: "offline", error: "" }, { id: "btcpayserver", installed: false, status: "offline", error: "" }, diff --git a/backend-mock/index.js b/backend-mock/index.js index 85c5b950..1f1ee3cb 100644 --- a/backend-mock/index.js +++ b/backend-mock/index.js @@ -1,5 +1,6 @@ const express = require("express"); const cors = require("cors"); +const WebSocket = require("ws"); const btcInfo = require("./sse/btc_info"); const lnInfo = require("./sse/ln_info"); const installedAppStatus = require("./sse/installed_app_status"); @@ -7,15 +8,20 @@ const systemInfo = require("./sse/system_info"); const hardwareInfo = require("./sse/hardware_info"); const walletBalance = require("./sse/wallet_balance"); const systemStartupInfo = require("./sse/system_startup_info"); -const util = require("./sse/util"); const setup = require("./setup"); const system = require("./system"); const apps = require("./apps"); const lightning = require("./lightning"); +const { createServer } = require("node:http"); require("dotenv").config(); const app = express(); +const server = createServer(app); + +const wss = new WebSocket.Server({ server, path: "/ws" }); +let ws = null; + app.use( cors({ credentials: true, origin: "http://localhost:3000" }), express.json(), @@ -31,49 +37,48 @@ app.use("/api/lightning", lightning); const PORT = 8000; -app.listen(PORT, () => { +server.listen(PORT, () => { console.info(`Server listening on http://localhost:${PORT}`); + console.info(`WebSocket server is running on ws://localhost:${PORT}/ws`); }); -// app.use('/', express.static('../build')); - /** - * Main SSE Handler + * Main WebSocket Handler */ -const eventsHandler = (request, response) => { - console.info("call to /api/sse/subscribe"); - const headers = { - "Content-Type": "text/event-stream", - Connection: "keep-alive", - "Cache-Control": "no-cache", - }; - response.writeHead(200, headers); - const data = `data: null\n\n`; +wss.on("connection", (ws) => { + this.ws = ws; + console.info("WebSocket connection established on /ws"); - response.write(data); + // Handle incoming messages + ws.on("message", (message) => { + console.info(`Received message: ${message}`); + }); - const id = util.currClientId++; + // Handle errors + ws.on("error", (error) => { + console.error("Error occurred:", error); + }); - util.clients.push({ - id, - response, + // Handle disconnections + ws.on("close", () => { + console.info("Client disconnected"); }); - systemStartupInfo.systemStartupInfo(); - systemInfo.systemInfo(); - hardwareInfo.hardwareInfo(); - btcInfo.btcInfo(); - lnInfo.lnInfo(); - installedAppStatus.appStatus(); - walletBalance.walletBalance(); + // Send data from SSE handlers to the client + const sendData = () => { + console.info("Sending data to the client"); + systemStartupInfo.systemStartupInfo(ws); + systemInfo.systemInfo(ws); + hardwareInfo.hardwareInfo(ws); + btcInfo.btcInfo(ws); + lnInfo.lnInfo(ws); + installedAppStatus.appStatus(ws); + walletBalance.walletBalance(ws); + }; - request.on("close", () => { - // do nothing - }); -}; + // Send initial data to the client + sendData(); +}); -/** - * SSE Handler call - */ -app.get("/api/sse/subscribe", eventsHandler); +module.exports = { ws }; diff --git a/backend-mock/lightning.js b/backend-mock/lightning.js index 1813c026..fa1b6245 100644 --- a/backend-mock/lightning.js +++ b/backend-mock/lightning.js @@ -2,6 +2,7 @@ const express = require("express"); const router = express.Router(); const transactions = require("./transactions"); const util = require("./sse/util"); +const index = require("./index"); let WALLET_LOCKED = true; @@ -235,7 +236,7 @@ router.post("/unlock-wallet", (req, res) => { if (req.body.password === "password") { WALLET_LOCKED = false; - util.sendSSE("system_startup_info", { + util.sendData(index.ws, "system_startup_info", { bitcoin: "done", bitcoin_msg: "", lightning: "bootstrapping_after_unlock", @@ -243,7 +244,7 @@ router.post("/unlock-wallet", (req, res) => { }); setTimeout(() => { - util.sendSSE("system_startup_info", { + util.sendData(index.ws, "system_startup_info", { bitcoin: "done", bitcoin_msg: "", lightning: "done", diff --git a/backend-mock/package-lock.json b/backend-mock/package-lock.json index f7c2828c..1c16c3cc 100644 --- a/backend-mock/package-lock.json +++ b/backend-mock/package-lock.json @@ -12,7 +12,8 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", - "jsonwebtoken": "^9.0.1" + "jsonwebtoken": "^9.0.1", + "ws": "^8.18.0" } }, "node_modules/accepts": { @@ -923,6 +924,27 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/backend-mock/package.json b/backend-mock/package.json index 09bdeb2b..e8586cfa 100644 --- a/backend-mock/package.json +++ b/backend-mock/package.json @@ -17,6 +17,7 @@ "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", - "jsonwebtoken": "^9.0.1" + "jsonwebtoken": "^9.0.1", + "ws": "^8.18.0" } } diff --git a/backend-mock/sse/btc_info.js b/backend-mock/sse/btc_info.js index 4fc45948..6cd322eb 100644 --- a/backend-mock/sse/btc_info.js +++ b/backend-mock/sse/btc_info.js @@ -1,9 +1,9 @@ const util = require("./util"); -const btcInfo = () => { +const btcInfo = (ws) => { console.info("sending btc_info"); - util.sendSSE("btc_info", { + util.sendData(ws, "btc_info", { blocks: 25, headers: 25, verification_progress: 0.9999983702720613, diff --git a/backend-mock/sse/hardware_info.js b/backend-mock/sse/hardware_info.js index a55ed63e..53e476c0 100644 --- a/backend-mock/sse/hardware_info.js +++ b/backend-mock/sse/hardware_info.js @@ -1,9 +1,9 @@ const util = require("./util"); -const hardwareInfo = () => { +const hardwareInfo = (ws) => { console.info("sending hardware_info"); - util.sendSSE("hardware_info", { + util.sendData(ws, "hardware_info", { cpu_overall_percent: 1.57, cpu_per_cpu_percent: [1.64, 1.54, 1.54], vram_total_bytes: 3844000000, diff --git a/backend-mock/sse/installed_app_status.js b/backend-mock/sse/installed_app_status.js index e52da86b..db9fd356 100644 --- a/backend-mock/sse/installed_app_status.js +++ b/backend-mock/sse/installed_app_status.js @@ -1,8 +1,8 @@ const util = require("./util"); -const appStatus = () => { +const appStatus = (ws) => { console.info("sending installed_app_status"); - util.sendSSE("installed_app_status", [ + util.sendData(ws, "installed_app_status", [ { id: "rtl", installed: false, diff --git a/backend-mock/sse/ln_info.js b/backend-mock/sse/ln_info.js index 3c72fe55..79f4e229 100644 --- a/backend-mock/sse/ln_info.js +++ b/backend-mock/sse/ln_info.js @@ -1,9 +1,9 @@ const util = require("./util"); -const lnInfo = () => { +const lnInfo = (ws) => { console.info("sending ln_info"); - util.sendSSE("ln_info", { + util.sendData(ws, "ln_info", { implementation: "LND_GRPC", version: "0.17.3-beta commit=v0.17.3-beta", commit_hash: "13aa7f99248c7ee63989d3b62e0cbfe86d7b0964", diff --git a/backend-mock/sse/system_info.js b/backend-mock/sse/system_info.js index dd526e27..dc407380 100644 --- a/backend-mock/sse/system_info.js +++ b/backend-mock/sse/system_info.js @@ -1,9 +1,9 @@ const util = require("./util"); -const systemInfo = () => { +const systemInfo = (ws) => { console.info("sending system_info"); - util.sendSSE("system_info", { + util.sendData(ws, "system_info", { alias: "myBlitz", color: "#3399ff", platform: "raspiblitz", diff --git a/backend-mock/sse/system_startup_info.js b/backend-mock/sse/system_startup_info.js index d3098ddb..8dcb5c6c 100644 --- a/backend-mock/sse/system_startup_info.js +++ b/backend-mock/sse/system_startup_info.js @@ -1,14 +1,16 @@ const util = require("./util"); -const systemStartupInfo = () => { +const systemStartupInfo = (ws) => { console.info("sending system_startup_info"); - util.sendSSE("system_startup_info", { + const data = { bitcoin: "done", bitcoin_msg: "", - lightning: "locked", + lightning: "", lightning_msg: "Wallet locked, unlock it to enable full RPC access", - }); + }; + + util.sendData(ws, "system_startup_info", data); }; module.exports = { systemStartupInfo }; diff --git a/backend-mock/sse/util.js b/backend-mock/sse/util.js index b072d3c7..54b9c2ec 100644 --- a/backend-mock/sse/util.js +++ b/backend-mock/sse/util.js @@ -1,18 +1,7 @@ -/** - * @type {{id: number, response: Response}[]} - */ -let clients = []; -let currClientId = 0; - -/** - * @param {string} event the event to send - * @param {any} data data of the event - * @returns void - */ -const sendSSE = (event, data) => { - clients.forEach((client) => - client.response.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), - ); +const util = { + sendData: (ws, type, data) => { + ws.send(JSON.stringify({ type, data })); + }, }; -module.exports = { clients, currClientId, sendSSE }; +module.exports = util; diff --git a/backend-mock/sse/wallet_balance.js b/backend-mock/sse/wallet_balance.js index 00a3030e..96a64d69 100644 --- a/backend-mock/sse/wallet_balance.js +++ b/backend-mock/sse/wallet_balance.js @@ -1,9 +1,9 @@ const util = require("./util"); -const walletBalance = () => { +const walletBalance = (ws) => { console.info("sending wallet_balance"); - util.sendSSE("wallet_balance", { + util.sendData(ws, "wallet_balance", { onchain_confirmed_balance: 742363, onchain_total_balance: 742363, onchain_unconfirmed_balance: 200000000, diff --git a/src/context/app-context.tsx b/src/context/app-context.tsx index 5c40c17d..bf29d942 100644 --- a/src/context/app-context.tsx +++ b/src/context/app-context.tsx @@ -1,4 +1,4 @@ -import { SSEContext } from "./sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import { ACCESS_TOKEN, disableGutter, @@ -53,7 +53,7 @@ export const AppContext = createContext(appContextDefault); const AppContextProvider: FC = ({ children }) => { const { i18n } = useTranslation(); - const { evtSource, setEvtSource } = useContext(SSEContext); + const { websocket } = useContext(WebSocketContext); const [unit, setUnit] = useState(Unit.SAT); const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -69,16 +69,15 @@ const AppContextProvider: FC = ({ children }) => { localStorage.removeItem(ACCESS_TOKEN); // close EventSource on logout - if (evtSource) { - evtSource.close(); - setEvtSource(null); + if (websocket) { + websocket.close(); } setIsLoggedIn(false); disableGutter(); setWindowAlias(null); toast.dismiss(); navigate("/"); - }, [evtSource, setEvtSource, navigate]); + }, [websocket, navigate]); useEffect(() => { const settings = retrieveSettings(); diff --git a/src/context/sse-context.tsx b/src/context/ws-context.tsx similarity index 57% rename from src/context/sse-context.tsx rename to src/context/ws-context.tsx index 75a06762..5a6b7235 100644 --- a/src/context/sse-context.tsx +++ b/src/context/ws-context.tsx @@ -1,3 +1,4 @@ +import { WEBSOCKET_URL } from "@/hooks/use-ws"; import { AppStatus } from "@/models/app-status"; import { App } from "@/models/app.model"; import { BtcInfo } from "@/models/btc-info"; @@ -8,11 +9,17 @@ import { SystemStartupInfo } from "@/models/system-startup-info"; import { Transaction } from "@/models/transaction.model"; import { WalletBalance } from "@/models/wallet-balance"; import type { FC, PropsWithChildren } from "react"; -import { Dispatch, SetStateAction, createContext, useState } from "react"; +import { + createContext, + Dispatch, + SetStateAction, + useEffect, + useRef, + useState, +} from "react"; -export interface SSEContextType { - evtSource: EventSource | null; - setEvtSource: Dispatch>; +export interface WebSocketContextType { + websocket: WebSocket | null; systemInfo: SystemInfo; setSystemInfo: Dispatch>; btcInfo: BtcInfo; @@ -21,7 +28,6 @@ export interface SSEContextType { setLnInfo: Dispatch>; balance: WalletBalance; setBalance: Dispatch>; - appStatus: AppStatus[]; setAppStatus: Dispatch>; availableApps: App[]; @@ -36,9 +42,8 @@ export interface SSEContextType { setSystemStartupInfo: Dispatch>; } -export const sseContextDefault: SSEContextType = { - evtSource: null, - setEvtSource: () => {}, +export const websocketContextDefault: WebSocketContextType = { + websocket: null, systemInfo: {} as SystemInfo, setSystemInfo: () => {}, btcInfo: {} as BtcInfo, @@ -61,12 +66,12 @@ export const sseContextDefault: SSEContextType = { setSystemStartupInfo: () => {}, }; -export const SSEContext = createContext(sseContextDefault); - -export const SSE_URL = "/api/sse/subscribe"; +export const WebSocketContext = createContext( + websocketContextDefault, +); -const SSEContextProvider: FC = (props) => { - const [evtSource, setEvtSource] = useState(null); +const WebSocketContextProvider: FC = (props) => { + const [ws, setWs] = useState(null); const [systemInfo, setSystemInfo] = useState({ alias: "", color: "", @@ -132,15 +137,99 @@ const SSEContextProvider: FC = (props) => { const [systemStartupInfo, setSystemStartupInfo] = useState(null); - const contextValue: SSEContextType = { - evtSource, - setEvtSource, + const websocketRef = useRef(null); + + useEffect(() => { + const ws = new WebSocket(WEBSOCKET_URL); + + ws.onopen = () => { + console.log("WebSocket connected"); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.type) { + case "apps": + setAvailableApps((prev) => { + const apps = data.payload; + if (prev.length === 0) { + return apps; + } else { + return prev.map( + (old) => + apps.find((newApp: { id: string }) => old.id === newApp.id) || + old, + ); + } + }); + break; + case "installed_app_status": + setAppStatus((prev) => { + const status = data.data; + if (prev.length === 0) { + return status; + } else { + const currentIds = status.map((item: { id: any }) => item.id); + return prev + .filter((item) => !currentIds.includes(item.id)) + .concat(status); + } + }); + break; + case "transactions": + setTransactions((prev) => [data.data, ...prev]); + break; + case "install": + // handle install message + break; + case "system_info": + setSystemInfo((prev) => ({ ...prev, ...data.data })); + break; + case "btc_info": + setBtcInfo((prev) => ({ ...prev, ...data.data })); + break; + case "ln_info": + setLnInfo((prev) => ({ ...prev, ...data.data })); + break; + case "wallet_balance": + setBalance((prev) => ({ ...prev, ...data.data })); + break; + case "hardware_info": + setHardwareInfo((prev) => ({ ...prev, ...data.data })); + break; + case "system_startup_info": + setSystemStartupInfo((prev) => ({ ...prev, ...data.data })); + break; + default: + console.warn("Unknown message type:", data.type); + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + // handle error + }; + + ws.onclose = () => { + console.log("WebSocket disconnected"); + }; + + setWs(ws); + + return () => { + ws.close(); + }; + }, []); + + const contextValue: WebSocketContextType = { + websocket: websocketRef.current, systemInfo, setSystemInfo, btcInfo, setBtcInfo, - lnInfo: lnInfo, - setLnInfo: setLnInfo, + lnInfo, + setLnInfo, balance, setBalance, appStatus, @@ -158,10 +247,10 @@ const SSEContextProvider: FC = (props) => { }; return ( - + {props.children} - + ); }; -export default SSEContextProvider; +export default WebSocketContextProvider; diff --git a/src/hooks/use-sse.tsx b/src/hooks/use-sse.tsx deleted file mode 100644 index a6c96507..00000000 --- a/src/hooks/use-sse.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { AppContext } from "@/context/app-context"; -import { SSE_URL, SSEContext } from "@/context/sse-context"; -import { AppStatus } from "@/models/app-status"; -import { App } from "@/models/app.model"; -import { BtcInfo } from "@/models/btc-info"; -import { HardwareInfo } from "@/models/hardware-info"; -import { InstallAppData } from "@/models/install-app"; -import { LnInfo } from "@/models/ln-info"; -import { SystemInfo } from "@/models/system-info"; -import { SystemStartupInfo } from "@/models/system-startup-info"; -import { WalletBalance } from "@/models/wallet-balance"; -import { setWindowAlias } from "@/utils"; -import { availableApps } from "@/utils/availableApps"; -import { useCallback, useContext, useEffect } from "react"; -import { useTranslation } from "react-i18next"; -import { toast } from "react-toastify"; - -/** - * Establishes a SSE connection if not available yet & attaches / removes event listeners - * to the single events to update the SSEContext - * Use useContext(SSEContext) to get the data, is only used in Layout.tsx - * @returns the infos from the SSEContext - */ -function useSSE() { - const { t } = useTranslation(); - const sseCtx = useContext(SSEContext); - const appCtx = useContext(AppContext); - const { evtSource, setEvtSource } = sseCtx; - - const appInstallSuccessHandler = useCallback( - (installData: InstallAppData, appName: string) => { - if (installData.mode === "on") { - toast.success(t("apps.install_success", { appName })); - } else { - toast.success(t("apps.uninstall_success", { appName })); - } - }, - [t], - ); - - const appInstallErrorHandler = useCallback( - (installData: InstallAppData, appName: string) => { - if (installData.mode === "on") { - toast.error( - t("apps.install_failure", { - appName, - details: installData.details, - }), - ); - } else { - toast.error( - t("apps.uninstall_failure", { - appName, - details: installData.details, - }), - ); - } - }, - [t], - ); - - useEffect(() => { - if (!evtSource) { - setEvtSource(new EventSource(SSE_URL, { withCredentials: true })); - } - - const setApps = (event: MessageEvent) => { - sseCtx.setAvailableApps((prev: App[]) => { - const apps = JSON.parse(event.data); - if (prev.length === 0) { - return apps; - } else { - return prev.map( - (old: App) => - apps.find((newApp: App) => old.id === newApp.id) || old, - ); - } - }); - }; - - const setAppStatus = (event: MessageEvent) => { - sseCtx.setAppStatus((prev: AppStatus[]) => { - const status: AppStatus[] = JSON.parse(event.data); - if (prev.length === 0) { - return status; - } else { - const currentIds = status.map((item) => item.id); - - // remove items which get updated and concat arrays - return prev - .filter((item) => !currentIds.includes(item.id)) - .concat(status); - } - }); - }; - - const setTx = (event: MessageEvent) => { - const t = JSON.parse(event.data); - sseCtx.setTransactions((prev) => { - // add the newest transaction to the beginning - return [t, ...prev]; - }); - }; - - const setInstall = (event: MessageEvent) => { - toast.dismiss(); - const installAppData = JSON.parse(event.data) as InstallAppData; - const appName = availableApps[installAppData.id]?.name || ""; - if (installAppData.result === "fail") { - appInstallErrorHandler(installAppData, appName); - sseCtx.setInstallingApp(null); - return; - } - if (installAppData.result === "win") { - appInstallSuccessHandler(installAppData, appName); - sseCtx.setInstallingApp(null); - return; - } - const installing = installAppData.mode === "on"; - toast( - installing - ? `${t("apps.installing")} ${appName}` - : `${t("apps.uninstalling")} ${appName}`, - { - isLoading: true, - autoClose: false, - }, - ); - sseCtx.setInstallingApp(installAppData); - }; - - const setSystemInfo = (event: MessageEvent) => { - const message = JSON.parse(event.data); - if (message.alias) { - setWindowAlias(message.alias); - } - sseCtx.setSystemInfo((prev: SystemInfo) => { - return { - ...prev, - ...message, - }; - }); - }; - - const setBtcInfo = (event: MessageEvent) => { - sseCtx.setBtcInfo((prev: BtcInfo) => { - const message = JSON.parse(event.data); - - return { - ...prev, - ...message, - }; - }); - }; - - const setLnInfo = (event: MessageEvent) => { - sseCtx.setLnInfo((prev: LnInfo) => { - const message = JSON.parse(event.data); - - return { - ...prev, - ...message, - }; - }); - }; - - const setBalance = (event: MessageEvent) => { - sseCtx.setBalance((prev: WalletBalance) => { - const message = JSON.parse(event.data); - - return { - ...prev, - ...message, - }; - }); - }; - - const setHardwareInfo = (event: MessageEvent) => { - sseCtx.setHardwareInfo((prev: HardwareInfo | null) => { - const message = JSON.parse(event.data); - - return { - ...prev, - ...message, - }; - }); - }; - - const setSystemStartupInfo = (event: MessageEvent) => { - sseCtx.setSystemStartupInfo((prev: SystemStartupInfo | null) => { - const message = JSON.parse(event.data); - - return { - ...prev, - ...message, - }; - }); - }; - - const eventErrorHandler = (_event: Event) => { - appCtx.logout(); - }; - - if (evtSource) { - evtSource.addEventListener("error", eventErrorHandler); - evtSource.addEventListener("system_info", setSystemInfo); - evtSource.addEventListener("btc_info", setBtcInfo); - evtSource.addEventListener("ln_info", setLnInfo); - evtSource.addEventListener("wallet_balance", setBalance); - evtSource.addEventListener("transactions", setTx); - evtSource.addEventListener("installed_app_status", setAppStatus); - evtSource.addEventListener("apps", setApps); - evtSource.addEventListener("install", setInstall); - evtSource.addEventListener("hardware_info", setHardwareInfo); - evtSource.addEventListener("system_startup_info", setSystemStartupInfo); - } - - return () => { - // cleanup - if (evtSource) { - evtSource.removeEventListener("error", eventErrorHandler); - evtSource.removeEventListener("system_info", setSystemInfo); - evtSource.removeEventListener("btc_info", setBtcInfo); - evtSource.removeEventListener("ln_info", setLnInfo); - evtSource.removeEventListener("wallet_balance", setBalance); - evtSource.removeEventListener("transactions", setTx); - evtSource.removeEventListener("installed_app_status", setAppStatus); - evtSource.removeEventListener("apps", setApps); - evtSource.removeEventListener("install", setInstall); - evtSource.removeEventListener("hardware_info", setHardwareInfo); - evtSource.removeEventListener( - "system_startup_info", - setSystemStartupInfo, - ); - } - }; - }, [ - t, - evtSource, - appCtx, - sseCtx, - setEvtSource, - appInstallSuccessHandler, - appInstallErrorHandler, - ]); - - return { - evtSource: sseCtx.evtSource, - systemInfo: sseCtx.systemInfo, - btcInfo: sseCtx.btcInfo, - lnInfo: sseCtx.lnInfo, - balance: sseCtx.balance, - appStatus: sseCtx.appStatus, - transactions: sseCtx.transactions, - availableApps: sseCtx.availableApps, - installingApp: sseCtx.installingApp, - hardwareInfo: sseCtx.hardwareInfo, - systemStartupInfo: sseCtx.systemStartupInfo, - }; -} - -export default useSSE; diff --git a/src/hooks/use-ws.tsx b/src/hooks/use-ws.tsx new file mode 100644 index 00000000..5606fc4f --- /dev/null +++ b/src/hooks/use-ws.tsx @@ -0,0 +1,119 @@ +import { WebSocketContext } from "@/context/ws-context"; +import { InstallAppData } from "@/models/install-app"; +import { SystemInfo } from "@/models/system-info"; +import { setWindowAlias } from "@/utils"; +import { useCallback, useContext } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; + +export const WEBSOCKET_URL = "/ws"; + +function useWebSocket() { + const { t } = useTranslation(); + const wsCtx = useContext(WebSocketContext); + + const { + systemInfo, + btcInfo, + lnInfo, + balance, + appStatus, + transactions, + availableApps, + installingApp, + hardwareInfo, + systemStartupInfo, + } = wsCtx; + + const appInstallSuccessHandler = useCallback( + (installData: InstallAppData, appName: string) => { + if (installData.mode === "on") { + toast.success(t("apps.install_success", { appName })); + } else { + toast.success(t("apps.uninstall_success", { appName })); + } + }, + [t], + ); + + const appInstallErrorHandler = useCallback( + (installData: InstallAppData, appName: string) => { + if (installData.mode === "on") { + toast.error( + t("apps.install_failure", { + appName, + details: installData.details, + }), + ); + } else { + toast.error( + t("apps.uninstall_failure", { + appName, + details: installData.details, + }), + ); + } + }, + [t], + ); + + const handleInstall = useCallback( + (installAppData: InstallAppData) => { + toast.dismiss(); + // @ts-ignore + const appName = availableApps[installAppData.id]?.name || ""; + if (installAppData.result === "fail") { + appInstallErrorHandler(installAppData, appName); + wsCtx.setInstallingApp(null); + return; + } + if (installAppData.result === "win") { + appInstallSuccessHandler(installAppData, appName); + wsCtx.setInstallingApp(null); + return; + } + const installing = installAppData.mode === "on"; + toast( + installing + ? `${t("apps.installing")} ${appName}` + : `${t("apps.uninstalling")} ${appName}`, + { + isLoading: true, + autoClose: false, + }, + ); + wsCtx.setInstallingApp(installAppData); + }, + [wsCtx, appInstallSuccessHandler, appInstallErrorHandler, t], + ); + + const handleSystemInfo = useCallback( + (message: SystemInfo) => { + if (message.alias) { + setWindowAlias(message.alias); + } + wsCtx.setSystemInfo((prev: SystemInfo) => ({ + ...prev, + ...message, + })); + }, + [wsCtx], + ); + + return { + systemInfo, + btcInfo, + lnInfo, + balance, + appStatus, + transactions, + availableApps, + installingApp, + hardwareInfo, + systemStartupInfo, + handleInstall, + handleSystemInfo, + }; +} + +export default useWebSocket; diff --git a/src/index.tsx b/src/index.tsx index e3e17eea..c8f00e22 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,9 @@ import App from "./App"; import AppContextProvider from "./context/app-context"; -import SSEContextProvider from "./context/sse-context"; import "./i18n/config"; import "./index.css"; import ErrorBoundary from "@/ErrorBoundary"; +import WebSocketContextProvider from "@/context/ws-context"; import { NextUIProvider } from "@nextui-org/react"; import "i18next"; import { StrictMode } from "react"; @@ -29,7 +29,7 @@ root.render( - + {/* For persistent toasts over all pages */} @@ -37,7 +37,7 @@ root.render( - + , diff --git a/src/layouts/Header.tsx b/src/layouts/Header.tsx index c0afea02..d928215f 100644 --- a/src/layouts/Header.tsx +++ b/src/layouts/Header.tsx @@ -1,13 +1,13 @@ import DropdownMenu from "./DropdownMenu"; import RaspiBlitzMobileLogo from "@/assets/RaspiBlitz_Logo_Icon.svg?react"; import RaspiBlitzLogoDark from "@/assets/RaspiBlitz_Logo_Main_Negative.svg?react"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import { Bars3Icon } from "@heroicons/react/24/outline"; import { useContext, useEffect, useRef, useState } from "react"; import { NavLink } from "react-router-dom"; export default function Header() { - const { systemInfo } = useContext(SSEContext); + const { systemInfo } = useContext(WebSocketContext); const dropdown = useRef(null); const menu = useRef(null); const [showDropdown, setShowDropdown] = useState(false); diff --git a/src/layouts/Layout.tsx b/src/layouts/Layout.tsx index d4ec9181..902fbb74 100644 --- a/src/layouts/Layout.tsx +++ b/src/layouts/Layout.tsx @@ -1,12 +1,12 @@ import BottomNav from "./BottomNav"; import Header from "./Header"; import SideDrawer from "./SideDrawer"; -import useSSE from "@/hooks/use-sse"; +import useWs from "@/hooks/use-ws"; import type { FC, PropsWithChildren } from "react"; const Layout: FC = ({ children }) => { // use SSE for all components after login - useSSE(); + useWs(); return ( <>
diff --git a/src/layouts/SideDrawer.tsx b/src/layouts/SideDrawer.tsx index daeff0c0..c3912e1c 100644 --- a/src/layouts/SideDrawer.tsx +++ b/src/layouts/SideDrawer.tsx @@ -1,6 +1,6 @@ import AppStatusItem from "@/components/AppStatusItem"; import { AppContext } from "@/context/app-context"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import { ArrowRightStartOnRectangleIcon, Cog6ToothIcon, @@ -21,7 +21,7 @@ const navIconClasses = "inline w-10 h-10"; export const SideDrawer: FC = () => { const { logout } = useContext(AppContext); - const { appStatus } = useContext(SSEContext); + const { appStatus } = useContext(WebSocketContext); const { t } = useTranslation(); return ( diff --git a/src/pages/Apps/AppInfo.tsx b/src/pages/Apps/AppInfo.tsx index 9ea52da6..50c1ff45 100644 --- a/src/pages/Apps/AppInfo.tsx +++ b/src/pages/Apps/AppInfo.tsx @@ -1,6 +1,6 @@ import ImageCarousel from "./ImageCarousel"; import AppIcon from "@/components/AppIcon"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import PageLoadingScreen from "@/layouts/PageLoadingScreen"; import { availableApps } from "@/utils/availableApps"; import { checkError } from "@/utils/checkError"; @@ -10,7 +10,7 @@ import { PlusIcon, TrashIcon, } from "@heroicons/react/24/outline"; -import { Link, Button } from "@nextui-org/react"; +import { Button, Link } from "@nextui-org/react"; import { FC, useContext, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; @@ -21,7 +21,7 @@ export const AppInfo: FC = () => { const { appId } = useParams(); const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(true); - const { appStatus, installingApp } = useContext(SSEContext); + const { appStatus, installingApp } = useContext(WebSocketContext); const [imgs, setImgs] = useState([]); const { name } = availableApps[appId!]; const { author, repository } = availableApps[appId!]; diff --git a/src/pages/Apps/AppPage.tsx b/src/pages/Apps/AppPage.tsx index db656166..0241f105 100644 --- a/src/pages/Apps/AppPage.tsx +++ b/src/pages/Apps/AppPage.tsx @@ -1,4 +1,4 @@ -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import PageLoadingScreen from "@/layouts/PageLoadingScreen"; import { getHrefFromApp } from "@/utils"; import { availableApps } from "@/utils/availableApps"; @@ -9,7 +9,7 @@ export const AppInfo: FC = () => { const navigate = useNavigate(); const { appId } = useParams(); const [isLoading, setIsLoading] = useState(true); - const { appStatus } = useContext(SSEContext); + const { appStatus } = useContext(WebSocketContext); const { customComponent } = availableApps[appId!]; const app = appStatus.find((app) => app.id === appId); diff --git a/src/pages/Apps/customApps/Electrs.tsx b/src/pages/Apps/customApps/Electrs.tsx index 568ac8c3..1940e29b 100644 --- a/src/pages/Apps/customApps/Electrs.tsx +++ b/src/pages/Apps/customApps/Electrs.tsx @@ -1,5 +1,5 @@ import { Headline } from "@/components/Headline"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import PageLoadingScreen from "@/layouts/PageLoadingScreen"; import { AdvancedAppStatusElectron } from "@/models/advanced-app-status"; import { checkError } from "@/utils/checkError"; @@ -22,7 +22,7 @@ import { useNavigate } from "react-router-dom"; const Electrs = () => { const navigate = useNavigate(); const { t } = useTranslation(); - const { appStatus } = useContext(SSEContext); + const { appStatus } = useContext(WebSocketContext); const [isLoading, setIsLoading] = useState(true); const [appData, setAppData] = useState( null, diff --git a/src/pages/Apps/index.tsx b/src/pages/Apps/index.tsx index 94aa8dbc..2bf92394 100644 --- a/src/pages/Apps/index.tsx +++ b/src/pages/Apps/index.tsx @@ -1,6 +1,6 @@ import AppCardAlby from "./AppCardAlby"; import AppList from "./AppList"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import PageLoadingScreen from "@/layouts/PageLoadingScreen"; import { AppStatus } from "@/models/app-status"; import { enableGutter } from "@/utils"; @@ -13,7 +13,7 @@ import { toast } from "react-toastify"; export const Apps: FC = () => { const { t } = useTranslation(["translation", "apps"]); - const { appStatus } = useContext(SSEContext); + const { appStatus } = useContext(WebSocketContext); useEffect(() => { enableGutter(); diff --git a/src/pages/Home/BitcoinCard.tsx b/src/pages/Home/BitcoinCard.tsx index e3a93ca5..ad16eeb6 100644 --- a/src/pages/Home/BitcoinCard.tsx +++ b/src/pages/Home/BitcoinCard.tsx @@ -1,12 +1,12 @@ import LoadingBox from "@/components/LoadingBox"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import { checkPropsUndefined } from "@/utils"; import { FC, useContext } from "react"; import { useTranslation } from "react-i18next"; export const BitcoinCard: FC = () => { const { t } = useTranslation(); - const { btcInfo, systemInfo } = useContext(SSEContext); + const { btcInfo, systemInfo } = useContext(WebSocketContext); if (checkPropsUndefined({ btcInfo, systemInfo })) { return ; diff --git a/src/pages/Home/ConnectionCard.tsx b/src/pages/Home/ConnectionCard.tsx index dfd7439e..7d6e0ba0 100644 --- a/src/pages/Home/ConnectionCard.tsx +++ b/src/pages/Home/ConnectionCard.tsx @@ -1,6 +1,6 @@ import QRCodeModal from "./QRCodeModal"; import LoadingBox from "@/components/LoadingBox"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import useClipboard from "@/hooks/use-clipboard"; import { ClipboardDocumentCheckIcon, @@ -16,7 +16,7 @@ const HIDDEN_TEXT = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; export const ConnectionCard: FC = () => { const { t } = useTranslation(); - const { systemInfo, lnInfo } = useContext(SSEContext); + const { systemInfo, lnInfo } = useContext(WebSocketContext); const [showAddress, setShowAddress] = useState(true); const [showModal, setShowModal] = useState(false); diff --git a/src/pages/Home/HardwareCard.tsx b/src/pages/Home/HardwareCard.tsx index 4757250f..4549c05d 100644 --- a/src/pages/Home/HardwareCard.tsx +++ b/src/pages/Home/HardwareCard.tsx @@ -1,5 +1,5 @@ import LoadingBox from "@/components/LoadingBox"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import { FC, useContext } from "react"; import { useTranslation } from "react-i18next"; @@ -11,7 +11,7 @@ const bytesToGB = (bytes: number | undefined): string => { export const HardwareCard: FC = () => { const { t } = useTranslation(); - const { hardwareInfo } = useContext(SSEContext); + const { hardwareInfo } = useContext(WebSocketContext); if (!hardwareInfo) { return ( diff --git a/src/pages/Home/LightningCard.tsx b/src/pages/Home/LightningCard.tsx index b814f98b..21c6ddb3 100644 --- a/src/pages/Home/LightningCard.tsx +++ b/src/pages/Home/LightningCard.tsx @@ -1,6 +1,6 @@ import LoadingBox from "@/components/LoadingBox"; import { AppContext, Unit } from "@/context/app-context"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import { checkPropsUndefined } from "@/utils"; import { convertMSatToBtc, convertToString } from "@/utils/format"; import { FC, useContext } from "react"; @@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next"; export const LightningCard: FC = () => { const { t } = useTranslation(); const { unit } = useContext(AppContext); - const { lnInfo, balance } = useContext(SSEContext); + const { lnInfo, balance } = useContext(WebSocketContext); const { num_active_channels: activeChannels, diff --git a/src/pages/Home/WalletCard.tsx b/src/pages/Home/WalletCard.tsx index ec11fe5f..16c2bce4 100644 --- a/src/pages/Home/WalletCard.tsx +++ b/src/pages/Home/WalletCard.tsx @@ -1,5 +1,5 @@ import { AppContext, Unit } from "@/context/app-context"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import { convertMSatToBtc, convertSatToBtc, @@ -34,7 +34,7 @@ export const WalletCard: FC = ({ }) => { const { t } = useTranslation(); const { unit } = useContext(AppContext); - const { balance } = useContext(SSEContext); + const { balance } = useContext(WebSocketContext); const { onchain_total_balance: onchainBalance, diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index aa85af64..5fa3a739 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -11,7 +11,7 @@ import TransactionDetailModal from "./TransactionCard/TransactionDetailModal/Tra import UnlockModal from "./UnlockModal"; import WalletCard from "./WalletCard"; import { AppContext } from "@/context/app-context"; -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import { useInterval } from "@/hooks/use-interval"; import PageLoadingScreen from "@/layouts/PageLoadingScreen"; import { Transaction } from "@/models/transaction.model"; @@ -36,7 +36,7 @@ type ModalType = const Home: FC = () => { const { t } = useTranslation(); const { walletLocked, setWalletLocked } = useContext(AppContext); - const { balance, lnInfo, systemStartupInfo } = useContext(SSEContext); + const { balance, lnInfo, systemStartupInfo } = useContext(WebSocketContext); const [showModal, setShowModal] = useState(false); const [detailTx, setDetailTx] = useState(null); const [transactions, setTransactions] = useState([]); diff --git a/src/pages/Settings/VersionBox.tsx b/src/pages/Settings/VersionBox.tsx index 16bcab2e..1df1362f 100644 --- a/src/pages/Settings/VersionBox.tsx +++ b/src/pages/Settings/VersionBox.tsx @@ -1,4 +1,4 @@ -import { SSEContext } from "@/context/sse-context"; +import { WebSocketContext } from "@/context/ws-context"; import packageJson from "package.json"; import { FC, useContext } from "react"; import { useTranslation } from "react-i18next"; @@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next"; */ const VersionBox: FC = () => { const { t } = useTranslation(); - const { systemInfo } = useContext(SSEContext); + const { systemInfo } = useContext(WebSocketContext); const { platform_version, api_version } = systemInfo; diff --git a/src/utils/test-utils.tsx b/src/utils/test-utils.tsx index 9b75fe7c..6077740b 100644 --- a/src/utils/test-utils.tsx +++ b/src/utils/test-utils.tsx @@ -4,10 +4,10 @@ import { AppContextType, } from "@/context/app-context"; import { - SSEContext, - sseContextDefault, - SSEContextType, -} from "@/context/sse-context"; + WebSocketContext, + websocketContextDefault, + WebSocketContextType, +} from "@/context/ws-context"; import i18n from "@/i18n/test_config"; import { render, RenderOptions } from "@testing-library/react"; import { FC, PropsWithChildren, ReactElement } from "react"; @@ -15,7 +15,7 @@ import { I18nextProvider } from "react-i18next"; import { BrowserRouter } from "react-router-dom"; type Props = { - sseProps: SSEContextType; + sseProps: WebSocketContextType; appProps: AppContextType; }; @@ -26,9 +26,9 @@ const AllTheProviders: FC> = ({ }) => { return ( - @@ -40,7 +40,7 @@ const AllTheProviders: FC> = ({ > {children} - + ); }; @@ -49,7 +49,7 @@ const customRender = ( ui: ReactElement, options?: Omit & { providerOptions?: { - sseProps?: Partial; + sseProps?: Partial; appProps?: Partial; }; }, diff --git a/vite.config.ts b/vite.config.ts index e9770563..a6479dbd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,6 +32,12 @@ export default defineConfig({ changeOrigin: true, secure: false, }, + "/ws": { + target: "ws://localhost:8000", + changeOrigin: true, + secure: false, + ws: true, + }, }, }, test: {