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

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)
})