Add browser support with Socket.io transport, authentication, performance-optimized IPC, and CI/CD (#925)

This commit is contained in:
Copilot
2025-12-20 02:35:34 +01:00
committed by GitHub
parent 8285627c5f
commit 91df6de4d4
42 changed files with 2805 additions and 290 deletions

View File

@@ -0,0 +1,46 @@
{
"name": "MQTT Explorer Development",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-vscode.vscode-typescript-next",
"ms-azuretools.vscode-docker",
"eamodio.gitlens"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"typescript.tsdk": "node_modules/typescript/lib",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
}
},
"forwardPorts": [3000, 8080, 1883],
"portsAttributes": {
"3000": {
"label": "MQTT Explorer Server",
"onAutoForward": "notify"
},
"8080": {
"label": "Webpack Dev Server",
"onAutoForward": "notify"
},
"1883": {
"label": "MQTT Broker",
"onAutoForward": "ignore"
}
},
"postCreateCommand": "yarn install",
"remoteUser": "node"
}

View File

@@ -0,0 +1,21 @@
version: '3.8'
services:
app:
image: mcr.microsoft.com/devcontainers/javascript-node:20
volumes:
- ../..:/workspace:cached
command: sleep infinity
network_mode: service:mosquitto
environment:
- MQTT_EXPLORER_USERNAME=dev
- MQTT_EXPLORER_PASSWORD=dev123
mosquitto:
image: eclipse-mosquitto:2
ports:
- "1883:1883"
- "3000:3000"
- "8080:8080"
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro

View File

@@ -0,0 +1,4 @@
# Mosquitto configuration for development
listener 1883
allow_anonymous true
persistence false

View File

@@ -25,6 +25,10 @@ yarn install
# Build the project
yarn build
# Set password for browser testing
export MQTT_EXPLORER_USERNAME=admin
export MQTT_EXPLORER_PASSWORD=secretpassword
# Start the application
yarn start
@@ -322,5 +326,5 @@ yarn package-with-docker
- The app uses Electron (see `package.json` for version)
- MQTT communication is handled via [mqttjs](https://github.com/mqttjs/MQTT.js)
- All code changes should pass linting (`yarn lint`)
- Node.js version requirement: >= 18
- Node.js version requirement: >= 20
- The project uses workspace-like structure with separate package.json files for app and backend

View File

@@ -14,7 +14,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
node-version: '20'
- name: Get yarn cache directory path
id: yarn-cache-dir-path

View File

@@ -74,3 +74,53 @@ jobs:
run: echo '${{ steps.upload.outputs.file-url }}'
id: artifact-upload-step
- run: echo '<picture><img src="${{ steps.upload.outputs.file-url }}"></picture>' >> $GITHUB_STEP_SUMMARY
test-browser:
runs-on: ubuntu-latest
services:
mosquitto:
image: eclipse-mosquitto:2
ports:
- 1883:1883
options: >-
--health-cmd "mosquitto_sub -t '$SYS/#' -C 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Dependencies
run: yarn install --frozen-lockfile
- name: Build Browser Mode
run: yarn build:server
- name: Test App
run: yarn test:app
- name: Test Backend
run: yarn test:backend
- name: Start Server in Background
run: |
yarn start:server &
echo $! > server.pid
env:
MQTT_EXPLORER_USERNAME: test
MQTT_EXPLORER_PASSWORD: test123
PORT: 3000
- name: Wait for Server
run: |
timeout 30 bash -c 'until curl -f http://localhost:3000; do sleep 1; done'
- name: Browser Smoke Test
run: |
# Test server is running
curl -f http://localhost:3000 || exit 1
echo "Browser mode server is running successfully"
- name: Stop Server
if: always()
run: |
if [ -f server.pid ]; then
kill $(cat server.pid) || true
rm server.pid
fi

2
.gitignore vendored
View File

@@ -15,5 +15,5 @@ mqtt-explorer-mcp-screenshot.png
screenshot-mcp-*.png
test-mcp-introspection.js
# UI test artifacts
/data
test-screenshot-*.png

193
BROWSER_MODE.md Normal file
View File

@@ -0,0 +1,193 @@
# Browser Mode Documentation
MQTT Explorer now supports running as a web application served by a Node.js server, in addition to the existing Electron desktop app.
## Running in Browser Mode
### Quick Start
1. Build the application for browser mode:
```bash
yarn build:server
```
2. Start the server:
```bash
yarn start:server
```
3. Open your browser and navigate to `http://localhost:3000`
4. You'll be prompted to log in with credentials that were generated on server startup.
### Development Mode
To run in development mode with hot reload:
```bash
yarn dev:server
```
This starts both the webpack dev server and the backend server.
## Authentication
### Environment Variables
You can set custom authentication credentials using environment variables:
```bash
export MQTT_EXPLORER_USERNAME=admin
export MQTT_EXPLORER_PASSWORD=secretpassword
yarn start:server
```
### Generated Credentials
If no environment variables are set, the server will generate credentials on first startup and save them to `data/credentials.json`. The generated credentials will be printed to the console:
```
============================================================
Generated new credentials:
Username: user-abc123
Password: 123e4567-e89b-12d3-a456-426614174000
============================================================
Please save these credentials. They will be persisted to:
/path/to/data/credentials.json
============================================================
```
## Features
### Certificate Upload
In browser mode, certificate files are uploaded directly through the browser using the HTML5 File API. The certificates are:
- Read client-side as base64
- Stored in the connection configuration
- Used when establishing MQTT connections
### Data Storage
In browser mode, all data is stored on the server:
- Credentials: `data/credentials.json`
- Uploaded certificates: `data/certificates/`
- File uploads: `data/uploads/`
### Port Configuration
The default port is 3000. You can change it using the `PORT` environment variable:
```bash
PORT=8080 yarn start:server
```
## Architecture
### Client-Server Communication
- **Electron Mode**: Uses Electron IPC for communication between renderer and main process
- **Browser Mode**: Uses Socket.io WebSockets for real-time communication between browser and server
The application automatically detects the environment and uses the appropriate transport layer.
### Event Bus Abstraction
Both Electron IPC and Socket.io implement the same `EventBusInterface`, allowing the application code to work seamlessly in both modes without modification.
## Differences from Electron Mode
### Browser Mode Limitations
1. **File System Access**: Limited to server-side operations
2. **Native Dialogs**: File selection uses browser file input instead of native dialogs
3. **Auto-Updates**: Not available in browser mode
4. **Tray Icon**: Not available in browser mode
### Browser Mode Advantages
1. **No Installation**: Access from any browser
2. **Cross-Platform**: Works on any device with a modern browser
3. **Remote Access**: Can be deployed on a server for remote access
4. **Multi-User**: Can support authentication for multiple users
## Security Considerations
1. **HTTPS**: For production, always use HTTPS to encrypt credentials and MQTT data
2. **Authentication**: Keep credentials secure and rotate them regularly
3. **Network**: Ensure the server is on a trusted network or behind a firewall
4. **Environment Variables**: Use environment variables for production credentials, not the generated ones
## Deployment
For production deployment:
1. Build the application:
```bash
yarn build:server
```
2. Set environment variables:
```bash
export MQTT_EXPLORER_USERNAME=your_username
export MQTT_EXPLORER_PASSWORD=your_secure_password
export PORT=3000
```
3. Start the server:
```bash
yarn start:server
```
4. Use a reverse proxy (nginx, Apache) to add HTTPS and additional security features
## Troubleshooting
### Debugging
Enable detailed Socket.IO connection and lifecycle debugging:
```bash
DEBUG=mqtt-explorer:socketio* yarn start:server
```
Available debug namespaces:
- `mqtt-explorer:socketio` - General Socket.IO events and metrics
- `mqtt-explorer:socketio:connect` - Client connection events
- `mqtt-explorer:socketio:disconnect` - Client disconnection and cleanup
- `mqtt-explorer:socketio:subscriptions` - Subscription lifecycle tracking
- `mqtt-explorer:socketio:connections` - MQTT connection ownership
This will log:
- Client connect/disconnect events
- Subscription counts per socket
- MQTT connection ownership tracking
- Memory leak detection metrics (subscriptions, handlers, connections)
Example output:
```
mqtt-explorer:socketio:connect Client connected: abc123de
mqtt-explorer:socketio [connect] clients=1 subscriptions=8 mqttConns=0 | socket[abc123de]: subs=8 conns=0
mqtt-explorer:socketio:connections Connection my-mqtt owned by socket abc123de (total: 1)
mqtt-explorer:socketio:disconnect Client disconnected: abc123de
mqtt-explorer:socketio:subscriptions Removed 8 subscriptions for socket abc123de
mqtt-explorer:socketio [disconnect] clients=0 subscriptions=0 mqttConns=0 | socket[abc123de]: subs=0 conns=0
```
### Authentication Fails
1. Check the console output for the generated credentials
2. Clear browser session storage: `sessionStorage.clear()` in browser console
3. Restart the server to regenerate credentials
### Connection Issues
1. Check that the server is running: `http://localhost:3000`
2. Check browser console for Socket.io connection errors
3. Verify firewall rules allow the port
### Certificate Upload Issues
In browser mode, certificates are handled differently:
- Use the file upload button to select certificate files
- Files are read and encoded client-side
- Large certificate files (>16KB) will be rejected

149
CI_CD.md Normal file
View File

@@ -0,0 +1,149 @@
# CI/CD Pipeline Documentation
## Overview
MQTT Explorer uses GitHub Actions for continuous integration and testing. The pipeline tests both Electron (desktop) and browser modes.
## Workflows
### Test Workflow (`.github/workflows/tests.yml`)
This workflow runs on pull requests to `master`, `beta`, and `release` branches.
#### Jobs
##### 1. `test` - Electron Mode Tests
Tests the traditional Electron desktop application:
- **Environment**: Custom Docker container (`ghcr.io/thomasnordquist/mqtt-explorer-ui-tests:latest`)
- **Steps**:
1. Install dependencies with frozen lockfile
2. Build the Electron application
3. Run unit tests (app + backend)
4. Run UI tests with video recording
5. Upload test video to S3
6. Display test results in GitHub summary
**Artifacts**: UI test video (GIF format) uploaded to S3
##### 2. `test-browser` - Browser Mode Tests
Tests the new browser/server mode:
- **Environment**: Ubuntu latest with Node.js 20
- **Services**:
- **Mosquitto MQTT Broker**: Eclipse Mosquitto v2 on port 1883
- Health checks enabled
- Anonymous connections allowed
- **Steps**:
1. Setup Node.js 20
2. Install dependencies
3. Build browser mode (`yarn build:server`)
4. Run unit tests (app + backend)
5. Start server in background with test credentials
6. Wait for server to be ready
7. Run browser smoke tests
8. Clean up server process
**Environment Variables**:
- `MQTT_EXPLORER_USERNAME=test`
- `MQTT_EXPLORER_PASSWORD=test123`
- `PORT=3000`
## Test Commands
The following npm scripts are used in CI/CD:
```bash
# Unit tests
yarn test # Run all tests (app + backend)
yarn test:app # Frontend tests only
yarn test:backend # Backend tests only
# Build
yarn build # Build Electron mode
yarn build:server # Build browser mode
# UI Tests (Electron only)
yarn ui-test # Run UI tests with video recording
```
## Adding New Tests
### For Electron Mode
Add tests to the `test` job. UI tests should be added to the test suite that `yarn ui-test` runs.
### For Browser Mode
Browser-specific tests should:
1. Use the pre-configured Mosquitto service
2. Connect to `mqtt://mosquitto:1883`
3. Test server endpoints at `http://localhost:3000`
Example:
```yaml
- name: Browser Integration Test
run: |
# Test MQTT connection through server
curl -X POST http://localhost:3000/api/test
```
## Local Testing
### Electron Mode
```bash
yarn build
yarn test
yarn ui-test
```
### Browser Mode
```bash
# Start Mosquitto in Docker
docker run -d -p 1883:1883 eclipse-mosquitto:2
# Build and test
yarn build:server
yarn test
# Start server
MQTT_EXPLORER_USERNAME=test MQTT_EXPLORER_PASSWORD=test123 yarn start:server
# Run manual tests
curl http://localhost:3000
```
## GitHub Codespaces / Devcontainer
The repository includes a devcontainer configuration that automatically sets up:
- Node.js 20
- MQTT broker (Mosquitto)
- All development dependencies
- Port forwarding for development
See [.devcontainer/README.md](.devcontainer/README.md) for details.
## Troubleshooting
### Browser Tests Failing
1. **Server won't start**: Check if port 3000 is already in use
2. **MQTT connection fails**: Ensure Mosquitto service is healthy
3. **Timeout errors**: Increase timeout in "Wait for Server" step
### Electron Tests Failing
1. **UI tests timeout**: Check if the Docker container has display access
2. **Build fails**: Verify all dependencies are in yarn.lock
## Future Improvements
- [ ] Add E2E browser tests with Playwright
- [ ] Test WebSocket connections in browser mode
- [ ] Add performance benchmarks
- [ ] Test with different MQTT broker versions
- [ ] Add security scanning for browser mode

View File

@@ -18,8 +18,22 @@ Downloads can be found at the link above.
This page is dedicated to its development.
Pull-Requests and error reports are welcome.
## Quick Start with GitHub Codespaces
The fastest way to start developing is with GitHub Codespaces:
1. Click the green "Code" button above
2. Select "Codespaces" tab
3. Click "Create codespace on [branch]"
4. Wait for the environment to set up (includes Node.js and MQTT broker)
5. Run `yarn dev:server` to start development
The devcontainer includes a pre-configured MQTT broker and all development tools. See [.devcontainer/README.md](.devcontainer/README.md) for details.
## Run from sources
### Desktop Application (Electron)
```bash
npm install -g yarn
yarn
@@ -27,8 +41,23 @@ yarn build
yarn start
```
### Browser Mode (Web Application)
MQTT Explorer can also run as a web application served by a Node.js server:
```bash
npm install -g yarn
yarn
yarn build:server
yarn start:server
```
Then open your browser to `http://localhost:3000`. For more details, see [BROWSER_MODE.md](BROWSER_MODE.md).
## Develop
### Desktop Application
Launch Application
```bash
@@ -37,6 +66,16 @@ yarn
yarn dev
```
### Browser Mode
Launch in development mode with hot reload:
```bash
npm install -g yarn
yarn
yarn dev:server
```
The `app` directory contains all the rendering logic, the `backend` directory currently contains the models, tests, connection management, `src` contains all the electron bindings. [mqttjs](https://github.com/mqttjs/MQTT.js) is used to facilitate communication to MQTT brokers.
## Automated Tests

View File

@@ -10,7 +10,7 @@
"mochatest": "mocha --require ts-node/register --require source-map-support/register --recursive src/*/**/*.spec.ts"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"author": "",
"license": "CC-BY-ND-4.0",
@@ -28,6 +28,7 @@
"d3-shape": "^1.3.5",
"diff": "^4.0.1",
"dot-prop": "^5.0.0",
"events": "^3.3.0",
"get-value": "^3.0.1",
"immutable": "^4.0.0-rc.12",
"in-viewport": "^3.6.0",
@@ -37,7 +38,9 @@
"lodash.throttle": "^4.1.1",
"moving-average": "^1.0.0",
"number-abbreviate": "^2.0.0",
"os-browserify": "^0.3.0",
"parse-duration": "^0.1.1",
"path-browserify": "^1.0.1",
"prismjs": "^1.15.0",
"react": "^16.11",
"react-ace": "^8",
@@ -51,7 +54,8 @@
"redux-batched-actions": "0.5",
"redux-thunk": "^2.3.0",
"sha1": "^1.1.1",
"socket.io-client": "^2.2.0",
"socket.io-client": "^4.8.1",
"url": "^0.11.4",
"uuid": "7"
},
"devDependencies": {
@@ -66,7 +70,7 @@
"@types/react-redux": "^7.0.9",
"@types/react-resize-detector": "^4.0.1",
"@types/sha1": "^1.1.1",
"@types/socket.io-client": "^1.4.32",
"@types/socket.io-client": "^3.0.0",
"@types/uuid": "^7.0.2",
"@types/vis": "^4.21.9",
"chai": "^4.2.0",

View File

@@ -0,0 +1,65 @@
import * as React from 'react'
import { LoginDialog } from './LoginDialog'
interface BrowserAuthWrapperProps {
children: React.ReactNode
}
const isBrowserMode =
typeof window !== 'undefined' &&
(typeof process === 'undefined' || process.env?.BROWSER_MODE === 'true')
export function BrowserAuthWrapper(props: BrowserAuthWrapperProps) {
const [isAuthenticated, setIsAuthenticated] = React.useState(false)
const [loginError, setLoginError] = React.useState<string | undefined>()
const [showLogin, setShowLogin] = React.useState(false)
React.useEffect(() => {
if (!isBrowserMode) {
// Not in browser mode, skip authentication
setIsAuthenticated(true)
return
}
// Check if already authenticated
const username = sessionStorage.getItem('mqtt-explorer-username')
const password = sessionStorage.getItem('mqtt-explorer-password')
if (username && password) {
// Try to use stored credentials
setIsAuthenticated(true)
} else {
// Show login dialog
setShowLogin(true)
}
}, [])
const handleLogin = async (username: string, password: string) => {
try {
// Store credentials in session storage
sessionStorage.setItem('mqtt-explorer-username', username)
sessionStorage.setItem('mqtt-explorer-password', password)
// The socket will use these credentials on next connection
setIsAuthenticated(true)
setShowLogin(false)
setLoginError(undefined)
// Reload to reinitialize socket with new auth
window.location.reload()
} catch (error) {
setLoginError('Login failed. Please check your credentials.')
}
}
if (!isBrowserMode) {
// Not in browser mode, render children directly
return <>{props.children}</>
}
if (!isAuthenticated) {
return <LoginDialog open={showLogin} onLogin={handleLogin} error={loginError} />
}
return <>{props.children}</>
}

View File

@@ -0,0 +1,140 @@
import * as React from 'react'
import ClearAdornment from '../helper/ClearAdornment'
import Lock from '@material-ui/icons/Lock'
import { bindActionCreators } from 'redux'
import { Button, Theme, Tooltip, Typography } from '@material-ui/core'
import { CertificateParameters, ConnectionOptions } from '../../model/ConnectionOptions'
import { CertificateTypes } from '../../actions/ConnectionManager'
import { connect } from 'react-redux'
import { connectionManagerActions } from '../../actions'
import { withStyles } from '@material-ui/styles'
import { rendererRpc } from '../../../../events'
import { RpcEvents } from '../../../../events/EventsV2'
function BrowserCertificateFileSelection(props: {
certificateType: CertificateTypes
title: string
certificate?: CertificateParameters
classes: any
actions: {
connectionManager: typeof connectionManagerActions
}
connection: ConnectionOptions
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null)
const clearCertificate = React.useCallback(() => {
props.actions.connectionManager.updateConnection(props.connection.id, {
[props.certificateType]: undefined,
})
}, [props.connection, props.certificateType])
const handleFileSelect = React.useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) {
return
}
try {
// Read file content
const reader = new FileReader()
reader.onload = async e => {
const content = e.target?.result
if (typeof content === 'string') {
// Convert to base64
const base64Data = content.split(',')[1] || content
// Upload via IPC instead of HTTP POST
const result = await rendererRpc.call(RpcEvents.uploadCertificate, {
filename: file.name,
data: base64Data,
})
// Create certificate parameters
const certificate: CertificateParameters = {
name: result.name,
data: result.data,
}
// Update connection
props.actions.connectionManager.updateConnection(props.connection.id, {
[props.certificateType]: certificate,
})
}
}
reader.readAsDataURL(file)
} catch (error) {
console.error('Error uploading certificate:', error)
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
[props.connection.id, props.certificateType, props.actions.connectionManager]
)
const handleButtonClick = () => {
fileInputRef.current?.click()
}
return (
<span>
<input
ref={fileInputRef}
type="file"
accept=".pem,.crt,.cer,.key"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Tooltip title="Select certificate" placement="top">
<Button variant="contained" className={props.classes.button} onClick={handleButtonClick}>
<Lock /> {props.title}
</Button>
</Tooltip>
<ClearCertificate classes={props.classes} certificate={props.certificate} action={clearCertificate} />
</span>
)
}
function ClearCertificate(props: { classes: any; certificate?: CertificateParameters; action: () => void }) {
if (!props.certificate) {
return null
}
return (
<Tooltip title={props.certificate.name}>
<Typography className={props.classes.certificateName}>
<ClearAdornment action={props.action} value={props.certificate.name} />
{props.certificate.name}
</Typography>
</Tooltip>
)
}
const mapDispatchToProps = (dispatch: any) => {
return {
actions: {
connectionManager: bindActionCreators(connectionManagerActions, dispatch),
},
}
}
const styles = (theme: Theme) => ({
certificateName: {
width: '100%',
height: 'calc(1em + 4px)',
overflow: 'hidden' as 'hidden',
whiteSpace: 'nowrap' as 'nowrap',
textOverflow: 'ellipsis' as 'ellipsis',
color: theme.palette.text.hint,
},
button: {
marginTop: theme.spacing(3),
marginRight: theme.spacing(2),
},
})
export default connect(undefined, mapDispatchToProps)(withStyles(styles)(BrowserCertificateFileSelection))

View File

@@ -1,5 +1,6 @@
import * as React from 'react'
import CertificateFileSelection from './CertificateFileSelection'
import BrowserCertificateFileSelection from './BrowserCertificateFileSelection'
import Undo from '@material-ui/icons/Undo'
import { bindActionCreators } from 'redux'
import { Button, Grid } from '@material-ui/core'
@@ -8,6 +9,12 @@ import { connectionManagerActions } from '../../actions'
import { ConnectionOptions } from '../../model/ConnectionOptions'
import { Theme, withStyles } from '@material-ui/core/styles'
// Check if we're in browser mode
const isBrowserMode =
typeof window !== 'undefined' &&
(typeof process === 'undefined' || process.env?.BROWSER_MODE === 'true')
const CertSelector = isBrowserMode ? BrowserCertificateFileSelection : CertificateFileSelection
interface Props {
connection: ConnectionOptions
classes: any
@@ -45,7 +52,7 @@ class Certificates extends React.PureComponent<Props, State> {
<form noValidate={true} autoComplete="off">
<Grid container={true} spacing={3}>
<Grid item={true} xs={12} className={classes.gridPadding}>
<CertificateFileSelection
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.selfSignedCertificate}
title="Server Certificate (CA)"
@@ -53,7 +60,7 @@ class Certificates extends React.PureComponent<Props, State> {
/>
</Grid>
<Grid item={true} xs={12} className={classes.gridPadding}>
<CertificateFileSelection
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.clientCertificate}
title="Client Certificate"
@@ -61,7 +68,7 @@ class Certificates extends React.PureComponent<Props, State> {
/>
</Grid>
<Grid item={true} xs={12} className={classes.gridPadding}>
<CertificateFileSelection
<CertSelector
connection={this.props.connection}
certificate={this.props.connection.clientKey}
title="Client Key"

View File

@@ -0,0 +1,57 @@
import * as React from 'react'
import { Dialog, DialogTitle, DialogContent, DialogActions, TextField, Button, Typography } from '@material-ui/core'
interface LoginDialogProps {
open: boolean
onLogin: (username: string, password: string) => void
error?: string
}
export function LoginDialog(props: LoginDialogProps) {
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
props.onLogin(username, password)
}
return (
<Dialog open={props.open} disableEscapeKeyDown disableBackdropClick>
<form onSubmit={handleSubmit}>
<DialogTitle>Login to MQTT Explorer</DialogTitle>
<DialogContent>
{props.error && (
<Typography color="error" style={{ marginBottom: 16 }}>
{props.error}
</Typography>
)}
<TextField
autoFocus
margin="dense"
label="Username"
type="text"
fullWidth
value={username}
onChange={e => setUsername(e.target.value)}
required
/>
<TextField
margin="dense"
label="Password"
type="password"
fullWidth
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</DialogContent>
<DialogActions>
<Button type="submit" color="primary" variant="contained">
Login
</Button>
</DialogActions>
</form>
</Dialog>
)
}

View File

@@ -1,6 +1,5 @@
import compareVersions from 'compare-versions'
import electron from 'electron'
import os from 'os'
import React from 'react'
import axios from 'axios'
import Close from '@material-ui/icons/Close'
@@ -182,9 +181,10 @@ class UpdateNotifier extends React.PureComponent<Props, State> {
private assetForCurrentPlatform(asset: GithubAsset) {
let regex: RegExp
if (os.platform() === 'darwin') {
const platform = this.getPlatform()
if (platform === 'darwin') {
regex = /\.dmg$/
} else if (os.platform() === 'win32') {
} else if (platform === 'win32') {
regex = /\.exe$/
} else {
regex = /\.AppImage$/
@@ -193,6 +193,14 @@ class UpdateNotifier extends React.PureComponent<Props, State> {
return regex.test(asset.name)
}
private getPlatform(): string {
if (typeof window === 'undefined') return 'linux'
const userAgent = window.navigator.userAgent.toLowerCase()
if (userAgent.includes('mac')) return 'darwin'
if (userAgent.includes('win')) return 'win32'
return 'linux'
}
private renderDownloads() {
const latestUpdate = this.state.newerVersions[0]
if (!latestUpdate || !latestUpdate.assets) {

View File

@@ -10,6 +10,7 @@ import { connect, Provider } from 'react-redux'
import { ThemeProvider } from '@material-ui/styles'
import './utils/tracking'
import { themes } from './theme'
import { BrowserAuthWrapper } from './components/BrowserAuthWrapper'
const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const store = createStore(reducers, composeEnhancers(applyMiddleware(reduxThunk, batchDispatchMiddleware)))
@@ -33,7 +34,9 @@ const Application = connect(mapStateToProps)(ApplicationRenderer)
ReactDOM.render(
<Provider store={store}>
<Application />
<BrowserAuthWrapper>
<Application />
</BrowserAuthWrapper>
</Provider>,
document.getElementById('app')
)

12
app/src/mocks/electron.ts Normal file
View File

@@ -0,0 +1,12 @@
// Mock electron module for browser environment
export const shell = {
openExternal: (url: string) => {
if (typeof window !== 'undefined') {
window.open(url, '_blank')
}
},
}
export default {
shell,
}

View File

@@ -0,0 +1,102 @@
// Browser-specific webpack configuration
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpack = require('webpack')
const path = require('path')
module.exports = {
entry: {
app: './src/index.tsx',
bugtracking: './src/utils/bugtracking.ts',
},
output: {
chunkFilename: '[name].bundle.js',
filename: '[name].bundle.js',
path: `${__dirname}/build`,
},
optimization: {
minimize: false,
splitChunks: {
chunks: 'all',
minSize: 30000,
minChunks: 1,
maxAsyncRequests: 5,
maxInitialRequests: 3,
automaticNameDelimiter: '~',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/](react|react-dom|@material-ui|popper\.js|react|react-redux|prop-types|jss|redux|scheduler|react-transition-group)[\\/]/,
name: 'vendors',
chunks: 'all',
priority: -10,
},
default: {
name: 'default',
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
runtimeChunk: 'single',
},
devServer: {
hot: true,
liveReload: true,
},
target: 'web', // Changed from 'electron-renderer' to 'web'
mode: 'production',
devtool: 'source-map',
resolve: {
extensions: ['.ts', '.mjs', '.m.js', '.tsx', '.js', '.json'],
modules: ['node_modules', path.resolve(__dirname, 'node_modules')],
alias: {
electron: require.resolve('./src/mocks/electron.ts'),
},
fallback: {
// Browser fallbacks for Node.js modules
path: require.resolve('path-browserify'),
fs: false,
crypto: false,
url: require.resolve('url/'),
os: require.resolve('os-browserify/browser'),
events: require.resolve('events/'),
},
},
module: {
rules: [
{
test: /\.tsx?$/,
use: [
{
loader: 'ts-loader',
options: {
transpileOnly: true, // Skip type checking, we already did it with tsc
},
},
],
exclude: /node_modules/,
},
{ enforce: 'pre', test: /\.js$/, loader: 'source-map-loader' },
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpg|gif)$/i,
type: 'asset/resource',
},
],
},
plugins: [
new HtmlWebpackPlugin({ template: './index.html', file: './build/index.html', inject: false }),
new webpack.DefinePlugin({
'process.env.BROWSER_MODE': JSON.stringify('true'),
}),
new webpack.NormalModuleReplacementPlugin(/EventSystem[\\/]EventBus$/, resource => {
console.log('Replacing EventBus:', resource.request);
resource.request = resource.request.replace(/EventBus$/, 'BrowserEventBus');
}),
],
externals: {},
cache: false,
}

View File

@@ -188,6 +188,11 @@
resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.25.tgz#f077fdc0b5d0078d30893396ff4827a13f99e817"
integrity sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==
"@socket.io/component-emitter@~3.1.0":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
"@types/body-parser@*":
version "1.19.5"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.5.tgz#04ce9a3b677dc8bd681a17da1ab9835dc9d3ede4"
@@ -668,10 +673,12 @@
dependencies:
"@types/node" "*"
"@types/socket.io-client@^1.4.32":
version "1.4.36"
resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-1.4.36.tgz#e4f1ca065f84c20939e9850e70222202bd76ff3f"
integrity sha512-ZJWjtFBeBy1kRSYpVbeGYTElf6BqPQUkXDlHHD4k/42byCN5Rh027f4yARHCink9sKAkbtGZXEAmR0ZCnc2/Ag==
"@types/socket.io-client@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-3.0.0.tgz#d0b8ea22121b7c1df68b6a923002f9c8e3cefb42"
integrity sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==
dependencies:
socket.io-client "*"
"@types/sockjs@^0.3.36":
version "0.3.36"
@@ -873,11 +880,6 @@ acorn@^8.0.4, acorn@^8.7.1, acorn@^8.8.2:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
after@0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
integrity sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==
ajv-formats@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520"
@@ -967,11 +969,6 @@ array-flatten@1.1.1:
resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
arraybuffer.slice@~0.0.7:
version "0.0.7"
resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
assertion-error@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
@@ -998,21 +995,11 @@ axios@^0.28.0:
form-data "^4.0.0"
proxy-from-env "^1.1.0"
backo2@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
integrity sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-arraybuffer@0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812"
integrity sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==
batch@0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
@@ -1028,11 +1015,6 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
blob@0.0.5:
version "0.0.5"
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
body-parser@1.20.2:
version "1.20.2"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd"
@@ -1115,6 +1097,14 @@ bytes@3.1.2:
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
dependencies:
es-errors "^1.3.0"
function-bind "^1.1.2"
call-bind@^1.0.2, call-bind@^1.0.6, call-bind@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
@@ -1126,6 +1116,14 @@ call-bind@^1.0.2, call-bind@^1.0.6, call-bind@^1.0.7:
get-intrinsic "^1.2.4"
set-function-length "^1.2.1"
call-bound@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
dependencies:
call-bind-apply-helpers "^1.0.2"
get-intrinsic "^1.3.0"
camel-case@^4.1.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a"
@@ -1301,21 +1299,6 @@ compare-versions@^3.5.0:
resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62"
integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==
component-bind@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
integrity sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==
component-emitter@~1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17"
integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==
component-inherit@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
integrity sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==
compressible@~2.0.16:
version "2.0.18"
resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
@@ -1819,12 +1802,12 @@ debug@4.3.4, debug@^4.1.0:
dependencies:
ms "2.1.2"
debug@~3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
debug@~4.3.1, debug@~4.3.2:
version "4.3.7"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52"
integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==
dependencies:
ms "2.0.0"
ms "^2.1.3"
decamelize@^4.0.0:
version "4.0.0"
@@ -2005,6 +1988,15 @@ dot-prop@^5.0.0:
dependencies:
is-obj "^2.0.0"
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
dependencies:
call-bind-apply-helpers "^1.0.1"
es-errors "^1.3.0"
gopd "^1.2.0"
duplexer@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
@@ -2045,33 +2037,21 @@ encodeurl@~1.0.2:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
engine.io-client@~3.5.0:
version "3.5.3"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.5.3.tgz#3254f61fdbd53503dc9a6f9d46a52528871ca0d7"
integrity sha512-qsgyc/CEhJ6cgMUwxRRtOndGVhIu5hpL5tR4umSpmX/MvkFoIxUTM7oFMDQumHNzlNLwSVy6qhstFPoWTf7dOw==
engine.io-client@~6.6.1:
version "6.6.3"
resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.3.tgz#815393fa24f30b8e6afa8f77ccca2f28146be6de"
integrity sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==
dependencies:
component-emitter "~1.3.0"
component-inherit "0.0.3"
debug "~3.1.0"
engine.io-parser "~2.2.0"
has-cors "1.1.0"
indexof "0.0.1"
parseqs "0.0.6"
parseuri "0.0.6"
ws "~7.4.2"
xmlhttprequest-ssl "~1.6.2"
yeast "0.1.2"
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
engine.io-parser "~5.2.1"
ws "~8.17.1"
xmlhttprequest-ssl "~2.1.1"
engine.io-parser@~2.2.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz#57ce5611d9370ee94f99641b589f94c97e4f5da7"
integrity sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==
dependencies:
after "0.8.2"
arraybuffer.slice "~0.0.7"
base64-arraybuffer "0.1.4"
blob "0.0.5"
has-binary2 "~1.0.2"
engine.io-parser@~5.2.1:
version "5.2.3"
resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f"
integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==
enhanced-resolve@^5.0.0:
version "5.15.1"
@@ -2106,6 +2086,11 @@ es-define-property@^1.0.0:
dependencies:
get-intrinsic "^1.2.4"
es-define-property@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
es-errors@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
@@ -2116,6 +2101,13 @@ es-module-lexer@^1.2.1:
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5"
integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1"
integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==
dependencies:
es-errors "^1.3.0"
escalade@^3.1.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27"
@@ -2166,7 +2158,7 @@ eventemitter3@^4.0.0:
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
events@^3.2.0:
events@^3.2.0, events@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
@@ -2367,6 +2359,30 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4:
has-symbols "^1.0.3"
hasown "^2.0.0"
get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
dependencies:
call-bind-apply-helpers "^1.0.2"
es-define-property "^1.0.1"
es-errors "^1.3.0"
es-object-atoms "^1.1.1"
function-bind "^1.1.2"
get-proto "^1.0.1"
gopd "^1.2.0"
has-symbols "^1.1.0"
hasown "^2.0.2"
math-intrinsics "^1.1.0"
get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
dependencies:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
get-stream@^6.0.0:
version "6.0.1"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
@@ -2428,6 +2444,11 @@ gopd@^1.0.1:
dependencies:
get-intrinsic "^1.1.3"
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
graceful-fs@^4.1.2, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
@@ -2450,18 +2471,6 @@ handle-thing@^2.0.0:
resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==
has-binary2@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
dependencies:
isarray "2.0.1"
has-cors@1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
integrity sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==
has-flag@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
@@ -2484,6 +2493,11 @@ has-symbols@^1.0.3:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
has-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
has-tostringtag@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
@@ -2498,6 +2512,13 @@ hasown@^2.0.0:
dependencies:
function-bind "^1.1.2"
hasown@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003"
integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==
dependencies:
function-bind "^1.1.2"
he@1.2.0, he@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
@@ -2662,11 +2683,6 @@ in-viewport@^3.6.0:
resolved "https://registry.yarnpkg.com/in-viewport/-/in-viewport-3.6.0.tgz#c59b4cdcaa41adb5bf5b8fe390c7d34259891f4a"
integrity sha512-MhaJ7Pr3NhUyAfpULysTZZBUAYfJAX1O8PccW2gvXlbQduMrJz7qQQ5yzC7SAr/0g5LbeRk432yNjsLMCnYzJg==
indexof@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
integrity sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@@ -2835,11 +2851,6 @@ is-wsl@^3.1.0:
dependencies:
is-inside-container "^1.0.0"
isarray@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
integrity sha512-c2cu3UxbI+b6kR3fy0nRnAhodsvR9dx7U5+znCOzdj6IfP3upFURTr0Xl5BlQZNKZjEtxrmVyfSdeE3O57smoQ==
isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@@ -3116,6 +3127,11 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -3270,7 +3286,7 @@ ms@2.1.2:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@2.1.3:
ms@2.1.3, ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
@@ -3350,6 +3366,11 @@ object-inspect@^1.13.1:
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2"
integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
object-inspect@^1.13.3:
version "1.13.4"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
object-is@^1.1.5:
version "1.1.6"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07"
@@ -3409,6 +3430,11 @@ opener@^1.5.2:
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
os-browserify@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
integrity sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==
p-limit@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
@@ -3464,16 +3490,6 @@ parse-duration@^0.1.1:
resolved "https://registry.yarnpkg.com/parse-duration/-/parse-duration-0.1.3.tgz#c2c4d45d49513d544e129b2a5a07b9473545d19a"
integrity sha512-hMOZHfUmjxO5hMKn7Eft+ckP2M4nV4yzauLXiw3PndpkASnx5r8pDAMcOAiqxoemqWjMWmz4fOHQM6n6WwETXw==
parseqs@0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5"
integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==
parseuri@0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a"
integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==
parseurl@~1.3.2, parseurl@~1.3.3:
version "1.3.3"
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -3487,6 +3503,11 @@ pascal-case@^3.1.2:
no-case "^3.0.4"
tslib "^2.0.3"
path-browserify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
path-exists@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
@@ -3656,6 +3677,11 @@ proxy-from-env@^1.1.0:
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
punycode@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -3668,6 +3694,13 @@ qs@6.11.0:
dependencies:
side-channel "^1.0.4"
qs@^6.12.3:
version "6.14.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930"
integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==
dependencies:
side-channel "^1.1.0"
raf-schd@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
@@ -4171,6 +4204,35 @@ shell-quote@^1.8.1:
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680"
integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==
side-channel-list@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad"
integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==
dependencies:
es-errors "^1.3.0"
object-inspect "^1.13.3"
side-channel-map@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42"
integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
dependencies:
call-bound "^1.0.2"
es-errors "^1.3.0"
get-intrinsic "^1.2.5"
object-inspect "^1.13.3"
side-channel-weakmap@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea"
integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
dependencies:
call-bound "^1.0.2"
es-errors "^1.3.0"
get-intrinsic "^1.2.5"
object-inspect "^1.13.3"
side-channel-map "^1.0.1"
side-channel@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
@@ -4181,6 +4243,17 @@ side-channel@^1.0.4:
get-intrinsic "^1.2.4"
object-inspect "^1.13.1"
side-channel@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9"
integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==
dependencies:
es-errors "^1.3.0"
object-inspect "^1.13.3"
side-channel-list "^1.0.0"
side-channel-map "^1.0.1"
side-channel-weakmap "^1.0.2"
signal-exit@^3.0.3:
version "3.0.7"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
@@ -4200,31 +4273,23 @@ sirv@^2.0.3:
mrmime "^2.0.0"
totalist "^3.0.0"
socket.io-client@^2.2.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.5.0.tgz#34f486f3640dde9c2211fce885ac2746f9baf5cb"
integrity sha512-lOO9clmdgssDykiOmVQQitwBAF3I6mYcQAo7hQ7AM6Ny5X7fp8hIJ3HcQs3Rjz4SoggoxA1OgrQyY8EgTbcPYw==
socket.io-client@*, socket.io-client@^4.8.1:
version "4.8.1"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.8.1.tgz#1941eca135a5490b94281d0323fe2a35f6f291cb"
integrity sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==
dependencies:
backo2 "1.0.2"
component-bind "1.0.0"
component-emitter "~1.3.0"
debug "~3.1.0"
engine.io-client "~3.5.0"
has-binary2 "~1.0.2"
indexof "0.0.1"
parseqs "0.0.6"
parseuri "0.0.6"
socket.io-parser "~3.3.0"
to-array "0.1.4"
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.2"
engine.io-client "~6.6.1"
socket.io-parser "~4.2.4"
socket.io-parser@~3.3.0:
version "3.3.3"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.3.tgz#3a8b84823eba87f3f7624e64a8aaab6d6318a72f"
integrity sha512-qOg87q1PMWWTeO01768Yh9ogn7chB9zkKtQnya41Y355S0UmpXgpcrFwAgjYJxu9BdKug5r5e9YtVSeWhKBUZg==
socket.io-parser@~4.2.4:
version "4.2.4"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83"
integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==
dependencies:
component-emitter "~1.3.0"
debug "~3.1.0"
isarray "2.0.1"
"@socket.io/component-emitter" "~3.1.0"
debug "~4.3.1"
sockjs@^0.3.24:
version "0.3.24"
@@ -4441,11 +4506,6 @@ tiny-warning@^1.0.2:
resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
to-array@0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
integrity sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==
to-regex-range@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
@@ -4527,6 +4587,14 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
url@^0.11.4:
version "0.11.4"
resolved "https://registry.yarnpkg.com/url/-/url-0.11.4.tgz#adca77b3562d56b72746e76b330b7f27b6721f3c"
integrity sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==
dependencies:
punycode "^1.4.1"
qs "^6.12.3"
util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -4775,15 +4843,15 @@ ws@^8.16.0:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea"
integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==
ws@~7.4.2:
version "7.4.6"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
ws@~8.17.1:
version "8.17.1"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b"
integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==
xmlhttprequest-ssl@~1.6.2:
version "1.6.3"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6"
integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==
xmlhttprequest-ssl@~2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz#e9e8023b3f29ef34b97a859f584c5e6c61418e23"
integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==
y18n@^5.0.5:
version "5.0.8"
@@ -4828,11 +4896,6 @@ yargs@16.2.0:
y18n "^5.0.5"
yargs-parser "^20.2.2"
yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
integrity sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"

View File

@@ -12,7 +12,7 @@
"postinstall": "yarn build"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"author": "",
"license": "CC-BY-ND-4.0",

View File

@@ -2,14 +2,17 @@ import FileAsync from 'lowdb/adapters/FileAsync'
import fs from 'fs-extra'
import lowdb from 'lowdb'
import path from 'path'
import { backendRpc } from '../../events'
import { Rpc } from '../../events/EventSystem/Rpc'
import { storageClearEvent, storageLoadEvent, storageStoreEvent } from '../../events/StorageEvents'
export default class ConfigStorage {
private file: string
private database: any
constructor(file: string) {
private rpc: Rpc
constructor(file: string, rpc: Rpc) {
this.file = file
this.rpc = rpc
}
private async getDb() {
@@ -26,13 +29,13 @@ export default class ConfigStorage {
}
public async init() {
backendRpc.on(storageStoreEvent, async event => {
this.rpc.on(storageStoreEvent, async event => {
const db = await this.getDb()
await db.set(event.store, event.data).write()
return
})
backendRpc.on(storageLoadEvent, async event => {
this.rpc.on(storageLoadEvent, async event => {
const db = await this.getDb()
const data = await db.get(event.store).value()
return {
@@ -41,7 +44,7 @@ export default class ConfigStorage {
}
})
backendRpc.on(storageClearEvent, async event => {
this.rpc.on(storageClearEvent, async event => {
const db = await this.getDb()
const keys = await db.keys().value()
for (const key of keys) {

View File

@@ -4,15 +4,20 @@ import {
AddMqttConnection,
MqttMessage,
addMqttConnectionEvent,
backendEvents,
makeConnectionMessageEvent,
makeConnectionStateEvent,
makePublishEvent,
removeConnection,
} from '../../events'
import { EventBusInterface } from '../../events/EventSystem/EventBusInterface'
export class ConnectionManager {
private connections: { [s: string]: DataSource<any> } = {}
private backendEvents: EventBusInterface
constructor(backendEvents: EventBusInterface) {
this.backendEvents = backendEvents
}
private handleConnectionRequest = (event: AddMqttConnection) => {
const connectionId = event.id
@@ -28,12 +33,12 @@ export class ConnectionManager {
const connectionStateEvent = makeConnectionStateEvent(connectionId)
connection.stateMachine.onUpdate.subscribe(state => {
backendEvents.emit(connectionStateEvent, state)
this.backendEvents.emit(connectionStateEvent, state)
})
connection.connect(options)
this.handleNewMessagesForConnection(connectionId, connection)
backendEvents.subscribe(makePublishEvent(connectionId), (msg: MqttMessage) => {
this.backendEvents.subscribe(makePublishEvent(connectionId), (msg: MqttMessage) => {
this.connections[connectionId].publish(msg)
})
}
@@ -49,7 +54,7 @@ export class ConnectionManager {
let decoded_payload = null
decoded_payload = Base64Message.fromBuffer(buffer)
backendEvents.emit(messageEvent, {
this.backendEvents.emit(messageEvent, {
topic,
payload: decoded_payload,
qos: packet.qos,
@@ -60,8 +65,8 @@ export class ConnectionManager {
}
public manageConnections() {
backendEvents.subscribe(addMqttConnectionEvent, this.handleConnectionRequest)
backendEvents.subscribe(removeConnection, (connectionId: string) => {
this.backendEvents.subscribe(addMqttConnectionEvent, this.handleConnectionRequest)
this.backendEvents.subscribe(removeConnection, (connectionId: string) => {
this.removeConnection(connectionId)
})
}
@@ -69,7 +74,7 @@ export class ConnectionManager {
public removeConnection(connectionId: string) {
const connection = this.connections[connectionId]
if (connection) {
backendEvents.unsubscribeAll(makePublishEvent(connectionId))
this.backendEvents.unsubscribeAll(makePublishEvent(connectionId))
connection.disconnect()
delete this.connections[connectionId]
connection.stateMachine.onUpdate.removeAllListeners()

View File

@@ -2,96 +2,3 @@
# yarn lockfile v1
"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==
"@protobufjs/base64@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==
"@protobufjs/codegen@^2.0.4":
version "2.0.4"
resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb"
integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==
"@protobufjs/eventemitter@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==
"@protobufjs/fetch@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==
dependencies:
"@protobufjs/aspromise" "^1.1.1"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/float@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==
"@protobufjs/inquire@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
integrity sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==
"@protobufjs/path@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==
"@protobufjs/pool@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==
"@protobufjs/utf8@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==
"@types/long@^4.0.1":
version "4.0.2"
resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==
"@types/node@>=13.7.0":
version "20.12.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.2.tgz#9facdd11102f38b21b4ebedd9d7999663343d72e"
integrity sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==
dependencies:
undici-types "~5.26.4"
long@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
protobufjs@^6.11.4:
version "6.11.4"
resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.4.tgz#29a412c38bf70d89e537b6d02d904a6f448173aa"
integrity sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==
dependencies:
"@protobufjs/aspromise" "^1.1.2"
"@protobufjs/base64" "^1.1.2"
"@protobufjs/codegen" "^2.0.4"
"@protobufjs/eventemitter" "^1.1.0"
"@protobufjs/fetch" "^1.1.0"
"@protobufjs/float" "^1.0.2"
"@protobufjs/inquire" "^1.1.0"
"@protobufjs/path" "^1.1.2"
"@protobufjs/pool" "^1.1.0"
"@protobufjs/utf8" "^1.1.0"
"@types/long" "^4.0.1"
"@types/node" ">=13.7.0"
long "^4.0.0"
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==

View File

@@ -0,0 +1,29 @@
// Browser-specific EventBus implementation using Socket.io
import io from 'socket.io-client'
import { SocketIOClientEventBus } from './SocketIOClientEventBus'
import { Rpc } from './Rpc'
// Get auth from sessionStorage or use empty (will show login dialog)
const username = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-username') || '' : ''
const password = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('mqtt-explorer-password') || '' : ''
// Connect to the server (same origin in browser mode)
const socket = io({
auth: {
username,
password,
},
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: Infinity,
transports: ['websocket', 'polling'],
})
export const rendererEvents = new SocketIOClientEventBus(socket)
export const rendererRpc = new Rpc(rendererEvents)
// In browser mode, the backend is on the server
// For compatibility, export same instances (renderer communicates with server backend via socket)
export const backendEvents = rendererEvents
export const backendRpc = rendererRpc

View File

@@ -1,24 +1,54 @@
import { IpcMain } from 'electron'
import { IpcMain, WebContents } from 'electron'
import { Event } from '../Events'
import { EventBusInterface } from './EventBusInterface'
export class IpcMainEventBus implements EventBusInterface {
private ipc: IpcMain
private client: any
private clients: Map<number, WebContents> = new Map() // webContentsId -> WebContents
private connectionOwners: Map<string, number> = new Map() // connectionId -> webContentsId
private currentClient: WebContents | undefined
constructor(ipc: IpcMain) {
this.ipc = ipc
}
public subscribe<MessageType>(subscribeEvent: Event<MessageType>, callback: (msg: MessageType) => void) {
console.log('subscribing', subscribeEvent.topic)
this.ipc.on(subscribeEvent.topic, (event: any, arg: any) => {
this.client = event.sender
const sender = event.sender as WebContents
this.currentClient = sender
// Track the client (O(1) operation)
if (!this.clients.has(sender.id)) {
this.clients.set(sender.id, sender)
// Clean up when window is closed
sender.once('destroyed', () => {
this.clients.delete(sender.id)
// Clean up owned connections
for (const [connectionId, webContentsId] of this.connectionOwners.entries()) {
if (webContentsId === sender.id) {
this.connectionOwners.delete(connectionId)
}
}
})
}
// Track connection ownership
if (subscribeEvent.topic === 'connection/add/mqtt' && arg?.id) {
this.connectionOwners.set(arg.id, sender.id)
}
// Remove connection ownership
if (subscribeEvent.topic === 'connection/remove' && typeof arg === 'string') {
this.connectionOwners.delete(arg)
}
callback(arg)
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
console.log('unsubscribeAll', event.topic)
this.ipc.removeAllListeners(event.topic)
}
@@ -27,8 +57,44 @@ export class IpcMainEventBus implements EventBusInterface {
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
if (!this.client.isDestroyed()) {
this.client.send(event.topic, msg)
const topic = event.topic
// RPC responses go only to the requesting client
if (topic.includes('/response/')) {
if (this.currentClient && !this.currentClient.isDestroyed()) {
this.currentClient.send(topic, msg)
}
return
}
// Connection-specific events - optimized with early pattern match
if (topic.startsWith('conn/')) {
const parts = topic.split('/')
let connectionId: string | undefined
if (parts.length === 2) {
connectionId = parts[1]
} else if (parts.length === 3 && (parts[1] === 'state' || parts[1] === 'publish')) {
connectionId = parts[2]
}
if (connectionId) {
const ownerWebContentsId = this.connectionOwners.get(connectionId)
if (ownerWebContentsId !== undefined) {
const ownerClient = this.clients.get(ownerWebContentsId)
if (ownerClient && !ownerClient.isDestroyed()) {
ownerClient.send(topic, msg)
return
}
}
}
}
// All other events go to all clients
this.clients.forEach(client => {
if (!client.isDestroyed()) {
client.send(topic, msg)
}
})
}
}

View File

@@ -0,0 +1,61 @@
import { IpcMain } from 'electron'
import { Event } from '../Events'
import { EventBusInterface } from './EventBusInterface'
import { MessageCodec } from './MessageCodec'
/**
* Enhanced IPC Main Event Bus with Protobuf support
*
* This version uses binary serialization for better performance
* while maintaining backward compatibility with the old JSON-based system.
*/
export class IpcMainEventBusV2 implements EventBusInterface {
private ipc: IpcMain
private client: any
private useBinary: boolean
constructor(ipc: IpcMain, useBinary: boolean = true) {
this.ipc = ipc
this.useBinary = useBinary
}
public subscribe<MessageType>(subscribeEvent: Event<MessageType>, callback: (msg: MessageType) => void) {
console.log('subscribing', subscribeEvent.topic, this.useBinary ? '(binary)' : '(json)')
this.ipc.on(subscribeEvent.topic, (event: any, arg: any) => {
this.client = event.sender
if (this.useBinary && arg instanceof Uint8Array) {
// Binary message - decode it
const { data } = MessageCodec.decodeWithPayload<MessageType>(arg)
callback(data)
} else {
// Regular JSON message
callback(arg)
}
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
console.log('unsubscribeAll', event.topic)
this.ipc.removeAllListeners(event.topic)
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
throw new Error('Not implemented') // Todo: implement
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
if (!this.client || this.client.isDestroyed()) {
return
}
if (this.useBinary) {
// Encode as binary
const binary = MessageCodec.encode(event.topic, msg)
this.client.send(event.topic, binary)
} else {
// Send as JSON (legacy)
this.client.send(event.topic, msg)
}
}
}

View File

@@ -0,0 +1,65 @@
import { CallbackStore } from './CallbackStore'
import { EventBusInterface } from './EventBusInterface'
import { Event } from '../Events'
import { IpcRenderer } from 'electron'
import { MessageCodec } from './MessageCodec'
/**
* Enhanced IPC Renderer Event Bus with Protobuf support
*
* This version uses binary serialization for better performance
* while maintaining backward compatibility with the old JSON-based system.
*/
export class IpcRendererEventBusV2 implements EventBusInterface {
private ipc: IpcRenderer
private callbacks: Array<CallbackStore> = []
private useBinary: boolean
constructor(ipc: IpcRenderer, useBinary: boolean = true) {
this.ipc = ipc
this.useBinary = useBinary
}
public subscribe<MessageType>(event: Event<MessageType>, callback: (msg: MessageType) => void) {
const wrappedCallback = (_: any, arg: any) => {
if (this.useBinary && arg instanceof Uint8Array) {
// Binary message - decode it
const { data } = MessageCodec.decodeWithPayload<MessageType>(arg)
callback(data)
} else {
// Regular JSON message
callback(arg)
}
}
console.log('subscribing', event.topic, this.useBinary ? '(binary)' : '(json)')
this.ipc.on(event.topic, wrappedCallback)
this.callbacks.push({
callback,
wrappedCallback,
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
this.ipc.removeAllListeners(event.topic)
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
const item = this.callbacks.find(store => store.callback === callback)
if (!item) {
return
}
this.ipc.removeListener(event.topic, item.wrappedCallback)
this.callbacks = this.callbacks.filter(a => a !== item)
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
if (this.useBinary) {
// Encode as binary
const binary = MessageCodec.encode(event.topic, msg)
this.ipc.send(event.topic, binary)
} else {
// Send as JSON (legacy)
this.ipc.send(event.topic, msg)
}
}
}

View File

@@ -0,0 +1,74 @@
/**
* Binary Message Codec using Protobuf
*
* This provides efficient binary serialization for IPC messages,
* avoiding JSON stringify/parse overhead.
*/
import * as protobuf from 'protobufjs'
// Define message schema
const messageSchema = {
nested: {
mqtt: {
nested: {
Envelope: {
fields: {
topic: { type: 'string', id: 1 },
payload: { type: 'bytes', id: 2 },
},
},
},
},
},
}
// Create root from JSON schema
const root = protobuf.Root.fromJSON(messageSchema)
const Envelope = root.lookupType('mqtt.Envelope')
export interface BinaryMessage {
topic: string
payload: Uint8Array
}
export class MessageCodec {
/**
* Encode a message to binary format
*/
public static encode(topic: string, data: any): Uint8Array {
// Serialize the payload to JSON, then to bytes
const jsonString = JSON.stringify(data)
const payloadBytes = new TextEncoder().encode(jsonString)
// Create protobuf envelope
const message = Envelope.create({
topic,
payload: payloadBytes,
})
// Encode to binary
return Envelope.encode(message).finish()
}
/**
* Decode a binary message
*/
public static decode(binary: Uint8Array): BinaryMessage {
const message = Envelope.decode(binary) as any
return {
topic: message.topic,
payload: message.payload,
}
}
/**
* Decode and parse payload as JSON
*/
public static decodeWithPayload<T>(binary: Uint8Array): { topic: string; data: T } {
const { topic, payload } = this.decode(binary)
const jsonString = new TextDecoder().decode(payload)
const data = JSON.parse(jsonString)
return { topic, data }
}
}

View File

@@ -0,0 +1,42 @@
import { Socket } from 'socket.io-client'
import { CallbackStore } from './CallbackStore'
import { EventBusInterface } from './EventBusInterface'
import { Event } from '../Events'
export class SocketIOClientEventBus implements EventBusInterface {
private socket: Socket
private callbacks: Array<CallbackStore> = []
constructor(socket: Socket) {
this.socket = socket
}
public subscribe<MessageType>(event: Event<MessageType>, callback: (msg: MessageType) => void) {
const wrappedCallback = (arg: any) => {
callback(arg)
}
console.log('subscribing', event.topic)
this.socket.on(event.topic, wrappedCallback)
this.callbacks.push({
callback,
wrappedCallback,
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
this.socket.removeAllListeners(event.topic)
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
const item = this.callbacks.find(store => store.callback === callback)
if (!item) {
return
}
this.socket.off(event.topic, item.wrappedCallback)
this.callbacks = this.callbacks.filter(a => a !== item)
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
this.socket.emit(event.topic, msg)
}
}

View File

@@ -0,0 +1,274 @@
import { Server as SocketIOServer, Socket } from 'socket.io'
import { Event } from '../Events'
import { EventBusInterface } from './EventBusInterface'
import Debug from 'debug'
const debug = Debug('mqtt-explorer:socketio')
const debugConnect = Debug('mqtt-explorer:socketio:connect')
const debugDisconnect = Debug('mqtt-explorer:socketio:disconnect')
const debugSubscriptions = Debug('mqtt-explorer:socketio:subscriptions')
const debugConnections = Debug('mqtt-explorer:socketio:connections')
const debugEmit = Debug('mqtt-explorer:socketio:emit')
interface SocketSubscription {
topic: string
handler: (arg: any) => void
}
export class SocketIOServerEventBus implements EventBusInterface {
private io: SocketIOServer
private clients: Map<string, Socket> = new Map() // socketId -> Socket
// Global handlers that apply to ALL sockets (like RPC endpoints)
private globalHandlers: Map<string, (socket: Socket, arg: any) => void> = new Map()
// Per-socket subscriptions for cleanup
private socketSubscriptions: Map<string, SocketSubscription[]> = new Map()
// Track which socket is currently processing a request
private currentSocket: Socket | undefined
// Map connectionId -> socketId to route messages to correct client
private connectionOwners: Map<string, string> = new Map()
// Track which connections to close when a socket disconnects
private socketConnections: Map<string, Set<string>> = new Map()
constructor(io: SocketIOServer) {
this.io = io
// Register connection handler once
this.io.on('connection', socket => {
debugConnect('Client connected: %s', socket.id)
this.clients.set(socket.id, socket)
this.socketSubscriptions.set(socket.id, [])
this.socketConnections.set(socket.id, new Set())
// Register all global handlers on this socket
this.globalHandlers.forEach((handler, topic) => {
this.registerHandlerOnSocket(socket, topic, handler)
})
// Log connection metrics
this.logConnectionMetrics('connect', socket.id)
socket.on('disconnect', () => {
debugDisconnect('Client disconnected: %s', socket.id)
this.cleanupSocket(socket)
this.clients.delete(socket.id)
this.logConnectionMetrics('disconnect', socket.id)
})
})
}
private logConnectionMetrics(event: 'connect' | 'disconnect', socketId: string) {
const totalClients = this.clients.size
const totalSubscriptions = Array.from(this.socketSubscriptions.values()).reduce((sum, subs) => sum + subs.length, 0)
const totalConnections = this.connectionOwners.size
const socketSubs = this.socketSubscriptions.get(socketId)?.length || 0
const socketConns = this.socketConnections.get(socketId)?.size || 0
debug(
'[%s] clients=%d subscriptions=%d mqttConns=%d | socket[%s]: subs=%d conns=%d',
event,
totalClients,
totalSubscriptions,
totalConnections,
socketId.substring(0, 8),
socketSubs,
socketConns
)
debugSubscriptions(
'Total subscriptions: %d across %d sockets (avg: %d per socket)',
totalSubscriptions,
totalClients,
totalClients > 0 ? Math.round(totalSubscriptions / totalClients) : 0
)
debugConnections(
'MQTT connections: %d total, %d owned by socket %s',
totalConnections,
socketConns,
socketId.substring(0, 8)
)
}
private registerHandlerOnSocket(socket: Socket, topic: string, handler: (socket: Socket, arg: any) => void) {
const wrappedHandler = (arg: any) => {
this.currentSocket = socket
// Track connection ownership when a connection is added
if (topic === 'connection/add/mqtt' && arg?.id) {
this.connectionOwners.set(arg.id, socket.id)
const socketConns = this.socketConnections.get(socket.id)
if (socketConns) {
socketConns.add(arg.id)
}
debugConnections(
'Connection %s owned by socket %s (total: %d)',
arg.id,
socket.id.substring(0, 8),
socketConns?.size || 0
)
}
// Remove connection ownership when a connection is removed
if (topic === 'connection/remove' && typeof arg === 'string') {
this.connectionOwners.delete(arg)
const socketConns = this.socketConnections.get(socket.id)
if (socketConns) {
socketConns.delete(arg)
}
debugConnections(
'Connection %s removed (socket %s remaining: %d)',
arg,
socket.id.substring(0, 8),
socketConns?.size || 0
)
}
handler(socket, arg)
}
socket.on(topic, wrappedHandler)
// Track subscription for cleanup
const subscriptions = this.socketSubscriptions.get(socket.id)
if (subscriptions) {
subscriptions.push({ topic, handler: wrappedHandler })
}
}
private cleanupSocket(socket: Socket) {
debugDisconnect('Cleaning up socket %s', socket.id)
// Remove all event listeners for this socket
const subscriptions = this.socketSubscriptions.get(socket.id)
if (subscriptions) {
subscriptions.forEach(({ topic, handler }) => {
socket.off(topic, handler)
})
this.socketSubscriptions.delete(socket.id)
debugSubscriptions('Removed %d subscriptions for socket %s', subscriptions.length, socket.id.substring(0, 8))
}
// Close all MQTT connections owned by this socket
const ownedConnections = this.socketConnections.get(socket.id)
if (ownedConnections && ownedConnections.size > 0) {
debugConnections(
'Socket %s owned %d connections, requesting cleanup',
socket.id.substring(0, 8),
ownedConnections.size
)
// Emit connection/remove for each owned connection
// This will be handled by ConnectionManager to actually close the MQTT connection
ownedConnections.forEach(connectionId => {
debugConnections('Auto-closing connection %s (owner disconnected)', connectionId)
// Simulate a remove request from this socket
const removeHandler = this.globalHandlers.get('connection/remove')
if (removeHandler) {
this.currentSocket = socket
removeHandler(socket, connectionId)
}
this.connectionOwners.delete(connectionId)
})
this.socketConnections.delete(socket.id)
}
// Remove from clients set
this.clients.delete(socket.id)
// Clear current socket if it was this one
if (this.currentSocket === socket) {
this.currentSocket = undefined
}
debugDisconnect('Cleanup complete for socket %s', socket.id.substring(0, 8))
}
public subscribe<MessageType>(subscribeEvent: Event<MessageType>, callback: (msg: MessageType) => void) {
const handler = (socket: Socket, arg: any) => {
this.currentSocket = socket
callback(arg)
}
// Store as global handler
this.globalHandlers.set(subscribeEvent.topic, handler)
// Register on all currently connected clients
this.clients.forEach(client => {
this.registerHandlerOnSocket(client, subscribeEvent.topic, handler)
})
}
public unsubscribeAll<MessageType>(event: Event<MessageType>) {
// Remove from global handlers
this.globalHandlers.delete(event.topic)
// Remove from all sockets
this.clients.forEach(client => {
const subscriptions = this.socketSubscriptions.get(client.id)
if (subscriptions) {
const toRemove = subscriptions.filter(s => s.topic === event.topic)
toRemove.forEach(({ handler }) => {
client.off(event.topic, handler)
})
// Update subscriptions list
this.socketSubscriptions.set(
client.id,
subscriptions.filter(s => s.topic !== event.topic)
)
}
})
}
public unsubscribe<MessageType>(event: Event<MessageType>, callback: any) {
throw new Error('Not implemented - use unsubscribeAll instead')
}
public emit<MessageType>(event: Event<MessageType>, msg: MessageType) {
const topic = event.topic
// Check if this is an RPC response (contains /response/ in topic)
if (topic.includes('/response/')) {
if (this.currentSocket && this.currentSocket.connected) {
this.currentSocket.emit(topic, msg)
}
return
}
// Check if this is a connection-specific event - optimized with early pattern match
// Patterns: conn/${connectionId}, conn/state/${connectionId}, conn/publish/${connectionId}
if (topic.startsWith('conn/')) {
const parts = topic.split('/')
let connectionId: string | undefined
if (parts.length === 2) {
// conn/${connectionId}
connectionId = parts[1]
} else if (parts.length === 3 && (parts[1] === 'state' || parts[1] === 'publish')) {
// conn/state/${connectionId} or conn/publish/${connectionId}
connectionId = parts[2]
}
if (connectionId) {
const ownerSocketId = this.connectionOwners.get(connectionId)
if (ownerSocketId) {
const ownerSocket = this.clients.get(ownerSocketId)
if (ownerSocket && ownerSocket.connected) {
ownerSocket.emit(topic, msg)
return
}
}
}
}
// All other events go to all clients
this.io.emit(topic, msg)
}
}

71
events/EventsV2.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Simplified Event System V2
*
* This provides a simpler, more type-safe way to define and use events.
* Instead of factory functions like makeConnectionStateEvent(id),
* you can now use: Events.connectionState(id)
*/
import { Base64MessageDTO } from '../backend/src/Model/Base64Message'
import { DataSourceState, MqttOptions } from '../backend/src/DataSource'
import { UpdateInfo } from 'builder-util-runtime'
import { RpcEvent } from './EventSystem/Rpc'
export type EventV2<MessageType> = {
topic: string
}
// Simple event definitions (no parameters)
export const Events = {
// Connection management
addMqttConnection: { topic: 'connection/add/mqtt' } as EventV2<AddMqttConnectionV2>,
removeConnection: { topic: 'connection/remove' } as EventV2<string>,
updateAvailable: { topic: 'app/update/available' } as EventV2<UpdateInfo>,
// Parameterized events (for connection-specific events)
connectionState: (connectionId: string) => ({ topic: `conn/state/${connectionId}` }) as EventV2<DataSourceState>,
connectionMessage: (connectionId: string) => ({ topic: `conn/${connectionId}` }) as EventV2<MqttMessageV2>,
publish: (connectionId: string) => ({ topic: `conn/publish/${connectionId}` }) as EventV2<MqttMessageV2>,
}
// RPC Events - type-safe request/response patterns
export const RpcEvents = {
getAppVersion: { topic: 'getAppVersion' } as RpcEvent<void, string>,
writeToFile: { topic: 'writeFile' } as RpcEvent<{ filePath: string; data: string; encoding?: string }, void>,
readFromFile: { topic: 'readFromFile' } as RpcEvent<{ filePath: string; encoding?: string }, Buffer>,
openDialog: { topic: 'openDialog' } as RpcEvent<OpenDialogOptionsV2, OpenDialogReturnValueV2>,
saveDialog: { topic: 'saveDialog' } as RpcEvent<SaveDialogOptionsV2, SaveDialogReturnValueV2>,
uploadCertificate: { topic: 'uploadCertificate' } as RpcEvent<CertificateUploadRequest, CertificateUploadResponse>,
}
// Type definitions
export interface AddMqttConnectionV2 {
id: string
options: MqttOptions
}
export interface MqttMessageV2 {
topic: string
payload: Base64MessageDTO | null
qos: 0 | 1 | 2
retain: boolean
messageId: number | undefined
}
export interface CertificateUploadRequest {
filename: string
data: string // base64 encoded
}
export interface CertificateUploadResponse {
name: string
data: string // base64 encoded
}
// Electron dialog types (re-exported for convenience)
import { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
export type OpenDialogOptionsV2 = OpenDialogOptions
export type OpenDialogReturnValueV2 = OpenDialogReturnValue
export type SaveDialogOptionsV2 = SaveDialogOptions
export type SaveDialogReturnValueV2 = SaveDialogReturnValue

View File

@@ -1,6 +1,7 @@
import { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron'
import { RpcEvent } from './EventSystem/Rpc'
// Legacy functions - use RpcEvents from EventsV2.ts for new code
export function makeOpenDialogRpc(): RpcEvent<OpenDialogOptions, OpenDialogReturnValue> {
return {
topic: 'openDialog',

View File

@@ -1,4 +1,5 @@
export * from './Events'
export * from './EventsV2'
export * from './EventSystem/EventDispatcher'
export * from './EventSystem/EventBus'
export * from './EventSystem/EventBusInterface'

View File

@@ -4,11 +4,12 @@
"description": "Explore your message queues",
"main": "dist/src/electron.js",
"engines": {
"node": ">=18"
"node": ">=20"
},
"private": "true",
"scripts": {
"start": "electron .",
"start:server": "npx tsc && node dist/src/server.js",
"test": "yarn test:app && yarn test:backend",
"test:app": "cd app && yarn test",
"test:backend": "cd backend && yarn test",
@@ -18,6 +19,9 @@
"dev": "npm-run-all --parallel dev:*",
"dev:app": "cd app && npm run dev",
"dev:electron": "tsc && electron . --development",
"dev:server": "npm-run-all --parallel dev:server:*",
"dev:server:app": "cd app && npx webpack-dev-server --config webpack.browser.config.js --mode development --progress",
"dev:server:backend": "tsc && node dist/src/server.js",
"lint": "npm-run-all --parallel lint:prettier lint:tslint lint:spellcheck",
"lint:fix": "npm-run-all lint:tslint:fix lint:prettier:fix",
"lint:prettier": "prettier --check \"**/*.ts{x,}\"",
@@ -26,6 +30,7 @@
"lint:tslint:fix": "tslint -p ./ --fix",
"lint:spellcheck": "cspell -e ./build -e \"node_modules\" \"**/*.ts{x,}\"",
"build": "tsc && cd app && yarn run build && cd ..",
"build:server": "npx tsc && cd app && npx webpack --config webpack.browser.config.js --mode production && cd ..",
"prepare-release": "ts-node scripts/prepare-release.ts",
"package": "ts-node package.ts",
"ui-test": "./scripts/uiTests.sh",
@@ -84,15 +89,18 @@
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^12.0.0",
"@semantic-release/git": "^10.0.1",
"@types/bcryptjs": "^3.0.0",
"@types/chai": "^4.1.7",
"@types/express": "^5.0.6",
"@types/fs-extra": "8",
"@types/lowdb": "^1.0.6",
"@types/mime": "^2.0.0",
"@types/mocha": "^7.0.2",
"@types/mustache": "4",
"@types/node": "^12.6.8",
"@types/node": "^25.0.3",
"@types/semver": "7",
"@types/sha1": "^1.1.1",
"@types/socket.io": "^3.0.2",
"@types/uuid": "^8.3.4",
"builder-util-runtime": "^9",
"chai": "^4.2.0",
@@ -120,18 +128,23 @@
"dependencies": {
"about-window": "^1.12.1",
"axios": "^0.28.0",
"bcryptjs": "^3.0.3",
"debug": "^4.3.4",
"dot-prop": "^5.0.0",
"electron-log": "4.4.6",
"electron-updater": "^4.6",
"express": "^5.2.1",
"fs-extra": "9",
"js-base64": "^3.7.2",
"json-to-ast": "^2.1.0",
"lowdb": "^1.0.0",
"mime": "^2.4.4",
"mqtt": "^4.3.6",
"protobufjs": "^8.0.0",
"sha1": "^1.1.1",
"socket.io": "^4.8.1",
"sparkplug-payload": "^1.0.3",
"uuid": "^8.3.2",
"uuid": "^13.0.0",
"yarn-run-all": "^3.1.1"
}
}

94
src/AuthManager.ts Normal file
View File

@@ -0,0 +1,94 @@
import * as fs from 'fs'
import * as path from 'path'
import * as bcrypt from 'bcryptjs'
import { v4 as uuidv4 } from 'uuid'
export interface Credentials {
username: string
passwordHash: string
}
export class AuthManager {
private credentialsPath: string
private credentials: Credentials | undefined
constructor(credentialsPath: string) {
this.credentialsPath = credentialsPath
}
public async initialize(): Promise<void> {
// Try to get credentials from environment variables
const envUsername = process.env.MQTT_EXPLORER_USERNAME
const envPassword = process.env.MQTT_EXPLORER_PASSWORD
if (envUsername && envPassword) {
// Use environment credentials
console.log('Using credentials from environment variables')
console.log('Username:', envUsername)
this.credentials = {
username: envUsername,
passwordHash: await bcrypt.hash(envPassword, 10),
}
return
}
// Try to load from file
if (fs.existsSync(this.credentialsPath)) {
try {
const data = fs.readFileSync(this.credentialsPath, 'utf8')
this.credentials = JSON.parse(data)
console.log('Loaded credentials from', this.credentialsPath)
console.log('Username:', this.credentials!.username)
return
} catch (error) {
console.error('Failed to load credentials from file:', error)
}
}
// Generate new credentials
const username = `user-${uuidv4().substring(0, 8)}`
const password = uuidv4()
console.log('='.repeat(60))
console.log('Generated new credentials:')
console.log('Username:', username)
console.log('Password:', password)
console.log('='.repeat(60))
console.log('Please save these credentials. They will be persisted to:')
console.log(this.credentialsPath)
console.log('='.repeat(60))
this.credentials = {
username,
passwordHash: await bcrypt.hash(password, 10),
}
// Save to file
try {
const dir = path.dirname(this.credentialsPath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(this.credentialsPath, JSON.stringify(this.credentials, null, 2))
console.log('Credentials saved successfully')
} catch (error) {
console.error('Failed to save credentials:', error)
}
}
public async verifyCredentials(username: string, password: string): Promise<boolean> {
if (!this.credentials) {
return false
}
if (username !== this.credentials.username) {
return false
}
return bcrypt.compare(password, this.credentials.passwordHash)
}
public getUsername(): string | undefined {
return this.credentials?.username
}
}

View File

@@ -19,7 +19,8 @@ import {
import { shouldAutoUpdate, handleAutoUpdate } from './autoUpdater'
import { registerCrashReporter } from './registerCrashReporter'
import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest'
import { backendRpc, getAppVersion, writeToFile, readFromFile } from '../events'
import { backendRpc, backendEvents, getAppVersion, writeToFile, readFromFile } from '../events'
import { RpcEvents } from '../events/EventsV2'
registerCrashReporter()
@@ -49,21 +50,35 @@ app.whenReady().then(() => {
backendRpc.on(getAppVersion, async () => app.getVersion())
backendRpc.on(writeToFile, async ({ filePath, data, encoding }) => {
await fsPromise.writeFile(filePath, Buffer.from(data, 'base64'), { encoding })
await fsPromise.writeFile(filePath, Buffer.from(data, 'base64'), { encoding: encoding as BufferEncoding })
})
backendRpc.on(readFromFile, async ({ filePath, encoding }) => {
return fsPromise.readFile(filePath, { encoding })
if (encoding) {
const content = await fsPromise.readFile(filePath, { encoding: encoding as BufferEncoding })
return Buffer.from(content)
}
return fsPromise.readFile(filePath)
})
// Certificate upload handler - works for both Electron and browser mode via IPC
backendRpc.on(RpcEvents.uploadCertificate, async ({ filename, data }) => {
// In Electron, we just return the data as-is since it's already read
// The client will use it directly
return {
name: filename,
data,
}
})
})
autoUpdater.logger = log
log.info('App starting...')
const connectionManager = new ConnectionManager()
const connectionManager = new ConnectionManager(backendEvents)
connectionManager.manageConnections()
const configStorage = new ConfigStorage(path.join(app.getPath('userData'), 'settings.json'))
const configStorage = new ConfigStorage(path.join(app.getPath('userData'), 'settings.json'), backendRpc)
configStorage.init()
// Keep a global reference of the window object, if you don't, the window will

177
src/server.ts Normal file
View File

@@ -0,0 +1,177 @@
import express from 'express'
import * as http from 'http'
import * as path from 'path'
import { Server } from 'socket.io'
import { promises as fsPromise } from 'fs'
import { Request, Response } from 'express'
import { AuthManager } from './AuthManager'
import { ConnectionManager } from '../backend/src/index'
import ConfigStorage from '../backend/src/ConfigStorage'
import { SocketIOServerEventBus } from '../events/EventSystem/SocketIOServerEventBus'
import { Rpc } from '../events/EventSystem/Rpc'
import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest'
import { getAppVersion, writeToFile, readFromFile } from '../events'
import { RpcEvents } from '../events/EventsV2'
const PORT = process.env.PORT || 3000
const CREDENTIALS_PATH = path.join(process.cwd(), 'data', 'credentials.json')
async function startServer() {
// Initialize authentication
const authManager = new AuthManager(CREDENTIALS_PATH)
await authManager.initialize()
// Create Express app
const app = express()
const server = http.createServer(app)
const io = new Server(server, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
allowEIO3: true, // Allow Engine.IO v3 clients (backwards compatibility)
transports: ['websocket', 'polling'], // Support both transports
pingTimeout: 60000, // Increase ping timeout
pingInterval: 25000, // Ping interval
})
// Authentication middleware for Socket.io
io.use(async (socket, next) => {
const { username, password } = socket.handshake.auth
if (!username || !password) {
return next(new Error('Authentication required'))
}
const isValid = await authManager.verifyCredentials(username, password)
if (!isValid) {
return next(new Error('Invalid credentials'))
}
console.log('Client authenticated:', username)
next()
})
// Initialize backend event bus with Socket.io
const backendEvents = new SocketIOServerEventBus(io)
const backendRpc = new Rpc(backendEvents)
// Initialize connection manager
const connectionManager = new ConnectionManager(backendEvents)
connectionManager.manageConnections()
// Initialize config storage
const configStorage = new ConfigStorage(path.join(process.cwd(), 'data', 'settings.json'), backendRpc)
configStorage.init()
// Setup RPC handlers for file operations
backendRpc.on(makeOpenDialogRpc(), async request => {
// In browser mode, file selection is handled client-side via upload
// Return empty result as this will be handled differently
return { canceled: true, filePaths: [] }
})
backendRpc.on(makeSaveDialogRpc(), async request => {
// In browser mode, file saving is handled client-side via download
return { canceled: true, filePath: undefined }
})
backendRpc.on(getAppVersion, async () => {
// Return version from package.json
try {
const packageJsonPath = path.join(__dirname, '..', '..', 'package.json')
const packageJsonData = await fsPromise.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(packageJsonData)
return packageJson.version
} catch (e) {
return '0.0.0'
}
})
backendRpc.on(writeToFile, async ({ filePath, data, encoding }) => {
// In browser mode, we store files in the server's data directory
const dataDir = path.join(process.cwd(), 'data', 'uploads')
const safePath = path.join(dataDir, path.basename(filePath))
try {
await fsPromise.mkdir(dataDir, { recursive: true })
if (encoding) {
await fsPromise.writeFile(safePath, Buffer.from(data, 'base64'), { encoding: encoding as BufferEncoding })
} else {
await fsPromise.writeFile(safePath, Buffer.from(data, 'base64'))
}
} catch (error) {
console.error('Error writing file:', error)
throw error
}
})
backendRpc.on(readFromFile, async ({ filePath, encoding }) => {
// In browser mode, files are read from the server's data directory
const dataDir = path.join(process.cwd(), 'data', 'uploads')
const safePath = path.join(dataDir, path.basename(filePath))
try {
if (encoding) {
const content = await fsPromise.readFile(safePath, { encoding: encoding as BufferEncoding })
return Buffer.from(content)
}
return await fsPromise.readFile(safePath)
} catch (error) {
console.error('Error reading file:', error)
throw error
}
})
// Certificate upload handler - via IPC for consistency
backendRpc.on(RpcEvents.uploadCertificate, async ({ filename, data }) => {
// Store certificate on server for browser mode
const dataDir = path.join(process.cwd(), 'data', 'certificates')
await fsPromise.mkdir(dataDir, { recursive: true })
const safePath = path.join(dataDir, path.basename(filename))
await fsPromise.writeFile(safePath, Buffer.from(data, 'base64'))
console.log('Certificate uploaded:', filename)
// Return the certificate data for client to use
return {
name: filename,
data,
}
})
// Serve static files
app.use(express.static(path.join(__dirname, '..', '..', 'app', 'build')))
// Serve index.html for all other routes (SPA)
app.use((req: Request, res: Response) => {
res.sendFile(path.join(__dirname, '..', '..', 'app', 'index.html'))
})
// Start server
server.listen(PORT, () => {
console.log('='.repeat(60))
console.log(`MQTT Explorer server running on http://localhost:${PORT}`)
console.log('='.repeat(60))
})
// Handle graceful shutdown
process.on('SIGTERM' as any, () => {
console.log('SIGTERM received, closing connections...')
connectionManager.closeAllConnections()
server.close()
})
process.on('SIGINT' as any, () => {
console.log('SIGINT received, closing connections...')
connectionManager.closeAllConnections()
server.close()
process.exit(0)
})
}
startServer().catch(error => {
console.error('Failed to start server:', error)
process.exit(1)
})

View File

@@ -32,7 +32,7 @@ const cleanUp = async (scenes: SceneBuilder, electronApp: ElectronApplication) =
await electronApp.close()
}
process.on('unhandledRejection', (error: Error | any) => {
process.on('unhandledRejection' as any, (error: Error | any) => {
console.error('unhandledRejection', error.message, error.stack)
process.exit(1)
})

View File

@@ -7,7 +7,7 @@ import { clearSearch, searchTree } from './scenarios/searchTree'
import { connectTo } from './scenarios/connect'
import { reconnect } from './scenarios/reconnect'
process.on('unhandledRejection', (error: Error | any) => {
process.on('unhandledRejection' as any, (error: Error | any) => {
console.error('unhandledRejection', error.message, error.stack)
process.exit(1)
})

View File

@@ -17,6 +17,8 @@
},
"include": [
"src/electron.ts",
"src/server.ts",
"src/AuthManager.ts",
"src/spec/electron.ts",
"src/spec/demoVideo.ts",
"src/spec/leakTest.ts",

660
yarn.lock

File diff suppressed because it is too large Load Diff