refactor: 迁移至 electron-vite 官方推荐目录结构

- electron/* -> src/main/* (主进程)
- electron/preload.ts -> src/preload/index.ts (预加载)
- src/electron-api.d.ts -> src/preload/index.d.ts (类型声明)
- src/* -> src/renderer/src/* (渲染进程)
- src/index.html -> src/renderer/index.html
- electron.vite.config.ts 简化为自动入口检测 (零配置)
- tsconfig.app.json -> tsconfig.web.json, 更新 includes/paths
- 构建产物 dist-electron/ -> out/ (修复 main/preload 输出覆盖 bug)
- 更新 package.json main 字段 + electron-builder files
- 更新 .gitignore + eslint ignores
- 更新 AGENTS.md 架构文档
This commit is contained in:
2026-07-10 23:46:34 +08:00
parent 157af4399f
commit eda30250c6
71 changed files with 112 additions and 127 deletions
+15
View File
@@ -0,0 +1,15 @@
import { safeStorage } from 'electron'
export function encrypt(text: string): string {
if (!safeStorage.isEncryptionAvailable()) {
return Buffer.from(text).toString('base64')
}
return safeStorage.encryptString(text).toString('base64')
}
export function decrypt(encoded: string): string {
if (!safeStorage.isEncryptionAvailable()) {
return Buffer.from(encoded, 'base64').toString('utf-8')
}
return safeStorage.decryptString(Buffer.from(encoded, 'base64'))
}
+65
View File
@@ -0,0 +1,65 @@
import { app, BrowserWindow, nativeTheme, nativeImage } from 'electron'
import { join } from 'path'
import { restoreAndTrack } from './win-state'
import { registerIpcHandlers } from './ipc-handlers'
import { initUpdater } from './updater'
let mainWindow: BrowserWindow | null = null
function getIconPath(): string {
return join(__dirname, '../../build/icons/icon_256.png')
}
function createWindow(): void {
const iconPath = getIconPath()
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 900,
minHeight: 600,
title: 'JRedisDesktop',
icon: nativeImage.createFromPath(iconPath),
backgroundColor: nativeTheme.shouldUseDarkColors ? '#0f0f1a' : '#f8fafc',
webPreferences: {
preload: join(__dirname, '../preload/index.mjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
frame: false,
titleBarStyle: 'hidden',
transparent: true,
})
// Linux taskbar icon
if (process.platform === 'linux' && mainWindow) {
mainWindow.setIcon(nativeImage.createFromPath(iconPath))
}
restoreAndTrack(mainWindow)
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
mainWindow.on('closed', () => { mainWindow = null })
nativeTheme.on('updated', () => {
mainWindow?.webContents.send('theme:os-updated', nativeTheme.shouldUseDarkColors)
})
}
app.whenReady().then(() => {
registerIpcHandlers()
createWindow()
initUpdater(mainWindow!)
})
app.on('window-all-closed', () => { app.quit() })
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
+303
View File
@@ -0,0 +1,303 @@
// IPC 通道处理器
import { ipcMain, BrowserWindow, nativeTheme, dialog } from 'electron'
import store, { type ConnectionConfig } from './store'
import { encrypt, decrypt } from './credential'
import * as redis from './redis'
// Helper: wrap handler with command logging
function withLog(cmd: string, fn: (...args: any[]) => any) {
return async (...args: any[]) => {
const start = Date.now()
try {
const result = await fn(...args)
redis.log('current', cmd, Date.now() - start)
return result
} catch (err) {
redis.log('current', cmd, Date.now() - start)
throw err
}
}
}
export function registerIpcHandlers(): void {
// Window
ipcMain.on('window:minimize', () => BrowserWindow.getFocusedWindow()?.minimize())
ipcMain.on('window:maximize', () => {
const win = BrowserWindow.getFocusedWindow()
if (win) win.isMaximized() ? win.unmaximize() : win.maximize()
})
ipcMain.on('window:close', () => BrowserWindow.getFocusedWindow()?.close())
// Theme
ipcMain.handle('theme:get', () => nativeTheme.shouldUseDarkColors)
ipcMain.on('theme:set', (_e, theme: 'system' | 'dark' | 'light') => {
nativeTheme.themeSource = theme
})
// Dialog
ipcMain.handle('dialog:openFile', async (_e, options: Electron.OpenDialogOptions) => {
const win = BrowserWindow.getFocusedWindow()
if (!win) return null
const result = await dialog.showOpenDialog(win, options)
if (result.canceled || result.filePaths.length === 0) return null
const fs = await import('fs')
try {
return { path: result.filePaths[0], content: fs.readFileSync(result.filePaths[0], 'utf-8') }
} catch { return null }
})
// Credential
ipcMain.handle('credential:encrypt', (_e, text: string) => encrypt(text))
ipcMain.handle('credential:decrypt', (_e, encoded: string) => decrypt(encoded))
// Storage - connections
ipcMain.handle('storage:getConnections', () => store.get('connections', []))
ipcMain.handle('storage:saveConnection', (_e, conn: ConnectionConfig) => {
const connections = store.get('connections', [])
const idx = connections.findIndex((c: ConnectionConfig) => c.id === conn.id)
if (idx >= 0) connections[idx] = conn
else connections.push(conn)
store.set('connections', connections)
return connections
})
ipcMain.handle('storage:deleteConnection', (_e, id: string) => {
const connections = store.get('connections', []).filter((c: ConnectionConfig) => c.id !== id)
store.set('connections', connections)
return connections
})
ipcMain.handle('storage:reorderConnections', (_e, ids: string[]) => {
const connections = store.get('connections', [])
const reordered = ids.map((id, i) => {
const conn = connections.find((c: ConnectionConfig) => c.id === id)!
conn.order = i
return conn
})
store.set('connections', reordered)
return reordered
})
// Storage - settings
ipcMain.handle('storage:getSettings', () => store.get('settings'))
ipcMain.handle('storage:saveSettings', (_e, settings) => {
store.set('settings', settings)
})
// Redis - 连接
ipcMain.handle('redis:connect', async (_e, id: string, opts) => {
try {
await redis.connect(id, opts)
return { success: true }
} catch (err: any) {
return { success: false, error: err.message }
}
})
ipcMain.handle('redis:disconnect', async (_e, id: string) => {
await redis.disconnect(id)
})
ipcMain.handle('redis:ping', async (_e, id: string) => {
return redis.ping(id)
})
ipcMain.handle('redis:isConnected', (_e, id: string) => {
return redis.isClientConnected(id)
})
// Redis - 服务器
ipcMain.handle('redis:execute', withLog('EXEC', async (_e, id: string, command: string, args: string[]) => {
return redis.execute(id, command, args)
}))
ipcMain.handle('redis:getInfo', async (_e, id: string) => {
return redis.getServerInfo(id)
})
ipcMain.handle('redis:selectDb', withLog('SELECT', async (_e, id: string, db: number) => {
await redis.selectDb(id, db)
}))
ipcMain.handle('redis:dbSize', async (_e, id: string) => {
return redis.dbSize(id)
})
ipcMain.handle('redis:slowLogGet', async (_e, id: string, count: number) => {
return redis.slowLogGet(id, count)
})
ipcMain.handle('redis:slowLogLen', async (_e, id: string) => {
return redis.slowLogLen(id)
})
// Redis - Key 操作
ipcMain.handle('redis:scanKeys', async (_e, id: string, cursor: string, pattern: string, count: number) => {
return redis.scanKeys(id, cursor, pattern, count)
})
ipcMain.handle('redis:getKeyType', async (_e, id: string, key: string) => {
return redis.getKeyType(id, key)
})
ipcMain.handle('redis:getKeyTTL', async (_e, id: string, key: string) => {
return redis.getKeyTTL(id, key)
})
ipcMain.handle('redis:setTTL', withLog('EXPIRE', async (_e, id: string, key: string, ttl: number) => {
await redis.setKeyTTL(id, key, ttl)
}))
ipcMain.handle('redis:deleteKey', withLog('DEL', async (_e, id: string, key: string) => {
await redis.deleteKey(id, key)
}))
ipcMain.handle('redis:renameKey', withLog('RENAME', async (_e, id: string, oldKey: string, newKey: string) => {
await redis.renameKey(id, oldKey, newKey)
}))
ipcMain.handle('redis:existsKey', async (_e, id: string, key: string) => {
return redis.existsKey(id, key)
})
ipcMain.handle('redis:batchDelete', withLog('DEL batch', async (_e, id: string, keys: string[]) => {
return redis.batchDelete(id, keys)
}))
ipcMain.handle('redis:memoryUsage', async (_e, id: string, key: string) => {
return redis.memoryUsage(id, key)
})
// Redis - String
ipcMain.handle('redis:getString', async (_e, id: string, key: string) => {
return redis.getStringValue(id, key)
})
ipcMain.handle('redis:setString', withLog('SET', async (_e, id: string, key: string, value: string) => {
await redis.setStringValue(id, key, value)
}))
// ReJson
ipcMain.handle('redis:rejsonGet', async (_e, id: string, key: string, path: string) => {
return redis.rejsonGet(id, key, path)
})
ipcMain.handle('redis:rejsonSet', withLog('JSON.SET', async (_e, id: string, key: string, path: string, value: string) => {
await redis.rejsonSet(id, key, path, value)
}))
// Redis - Hash
ipcMain.handle('redis:hashScan', async (_e, id: string, key: string, cursor: string, count: number) => {
return redis.getHashFields(id, key, cursor, count)
})
ipcMain.handle('redis:hashSet', withLog('HSET', async (_e, id: string, key: string, field: string, value: string) => {
await redis.setHashField(id, key, field, value)
}))
ipcMain.handle('redis:hashDel', withLog('HDEL', async (_e, id: string, key: string, field: string) => {
await redis.deleteHashField(id, key, field)
}))
// Redis - List
ipcMain.handle('redis:listRange', async (_e, id: string, key: string, start: number, stop: number) => {
return redis.listRange(id, key, start, stop)
})
ipcMain.handle('redis:listLength', async (_e, id: string, key: string) => {
return redis.listLength(id, key)
})
ipcMain.handle('redis:listPush', withLog('RPUSH', async (_e, id: string, key: string, values: string[]) => {
return redis.listPush(id, key, ...values)
}))
ipcMain.handle('redis:listSet', withLog('LSET', async (_e, id: string, key: string, index: number, value: string) => {
await redis.listSet(id, key, index, value)
}))
ipcMain.handle('redis:listRemove', withLog('LREM', async (_e, id: string, key: string, count: number, value: string) => {
return redis.listRemove(id, key, count, value)
}))
// Redis - Set
ipcMain.handle('redis:setMembers', async (_e, id: string, key: string) => {
return redis.setMembers(id, key)
})
ipcMain.handle('redis:setSize', async (_e, id: string, key: string) => {
return redis.setSize(id, key)
})
ipcMain.handle('redis:setAdd', withLog('SADD', async (_e, id: string, key: string, members: string[]) => {
return redis.setAdd(id, key, ...members)
}))
ipcMain.handle('redis:setRemove', withLog('SREM', async (_e, id: string, key: string, members: string[]) => {
return redis.setRemove(id, key, ...members)
}))
// Redis - Zset
ipcMain.handle('redis:zsetRange', async (_e, id: string, key: string, start: number, stop: number, withScores: boolean) => {
return redis.zsetRange(id, key, start, stop, withScores)
})
ipcMain.handle('redis:zsetSize', async (_e, id: string, key: string) => {
return redis.zsetSize(id, key)
})
ipcMain.handle('redis:zsetAdd', withLog('ZADD', async (_e, id: string, key: string, score: number, member: string) => {
return redis.zsetAdd(id, key, score, member)
}))
ipcMain.handle('redis:zsetRemove', withLog('ZREM', async (_e, id: string, key: string, members: string[]) => {
return redis.zsetRemove(id, key, ...members)
}))
// Redis - Stream
ipcMain.handle('redis:streamInfo', async (_e, id: string, key: string) => {
return redis.streamInfo(id, key)
})
ipcMain.handle('redis:streamRange', async (_e, id: string, key: string, start: string, end: string, count: number) => {
return redis.streamRange(id, key, start, end, count)
})
ipcMain.handle('redis:streamAdd', withLog('XADD', async (_e, id: string, key: string, streamId: string, fieldValues: string[]) => {
return redis.streamAdd(id, key, streamId, fieldValues)
}))
ipcMain.handle('redis:streamTrim', withLog('XTRIM', async (_e, id: string, key: string, maxLen: number) => {
return redis.streamTrim(id, key, maxLen)
}))
ipcMain.handle('redis:streamDel', withLog('XDEL', async (_e, id: string, key: string, ids: string[]) => {
return redis.streamDel(id, key, ...ids)
}))
// Redis - Stream Consumer Groups
ipcMain.handle('redis:streamGroups', async (_e, id: string, key: string) => {
return redis.streamGroups(id, key)
})
ipcMain.handle('redis:streamConsumers', async (_e, id: string, key: string, group: string) => {
return redis.streamConsumers(id, key, group)
})
ipcMain.handle('redis:streamGroupCreate', withLog('XGROUP CREATE', async (_e, id: string, key: string, group: string, startId: string, mkstream: boolean) => {
return redis.streamGroupCreate(id, key, group, startId, mkstream)
}))
ipcMain.handle('redis:streamGroupDestroy', withLog('XGROUP DESTROY', async (_e, id: string, key: string, group: string) => {
return redis.streamGroupDestroy(id, key, group)
}))
ipcMain.handle('redis:streamAck', withLog('XACK', async (_e, id: string, key: string, group: string, ids: string[]) => {
return redis.streamAck(id, key, group, ...ids)
}))
// Command Log
ipcMain.handle('redis:getCommandLog', () => {
return redis.getLog()
})
ipcMain.handle('redis:clearCommandLog', () => {
redis.clearLog()
})
// Format decode
ipcMain.handle('redis:decodeValue', (_e, value: string, format: string) => {
return redis.decodeValue(value, format as any)
})
ipcMain.handle('redis:detectFormat', (_e, value: string) => {
return redis.detectFormat(value)
})
ipcMain.handle('redis:customFormat', (_e, value: string, command: string, key: string) => {
return redis.customFormat(value, command, key)
})
// Pub/Sub
ipcMain.handle('redis:subscribe', async (_e, id: string, channels: string[], isPattern: boolean) => {
try {
redis.startSubscribe(id, channels, isPattern, _e.sender)
return { success: true }
} catch (err: any) {
return { success: false, error: err.message }
}
})
ipcMain.handle('redis:unsubscribe', async (_e, id: string) => {
redis.stopSubscribe(id)
})
// Monitor
ipcMain.handle('redis:monitor', async (_e, id: string) => {
try {
redis.startMonitor(id, _e.sender)
return { success: true }
} catch (err: any) {
return { success: false, error: err.message }
}
})
ipcMain.handle('redis:monitorStop', async (_e, id: string) => {
redis.stopMonitor(id)
})
}
+48
View File
@@ -0,0 +1,48 @@
// 命令日志记录器
export interface CommandEntry {
id: number
time: string
connection: string
command: string
duration: number
isWrite: boolean
}
const MAX_ENTRIES = 5000
let nextId = 1
const entries: CommandEntry[] = []
const WRITE_COMMANDS = new Set([
'SET', 'DEL', 'HSET', 'HDEL', 'RPUSH', 'LPUSH', 'LSET', 'LREM',
'SADD', 'SREM', 'ZADD', 'ZREM', 'XADD', 'XDEL', 'XTRIM',
'RENAME', 'EXPIRE', 'PERSIST', 'FLUSHDB', 'SELECT',
'SETEX', 'INCR', 'DECR', 'INCRBY', 'DECRBY', 'APPEND',
'HMSET', 'HINCRBY', 'HINCRBYFLOAT',
'LPOP', 'RPOP', 'BLPOP', 'BRPOP',
'ZINCRBY', 'ZREMRANGEBYRANK', 'ZREMRANGEBYSCORE',
'UNLINK', 'MOVE', 'RENAMENX',
])
export function log(connection: string, command: string, duration: number): void {
const isWrite = WRITE_COMMANDS.has(command.split(' ')[0].toUpperCase())
entries.push({
id: nextId++,
time: new Date().toISOString(),
connection,
command,
duration,
isWrite,
})
if (entries.length > MAX_ENTRIES) {
entries.splice(0, entries.length - MAX_ENTRIES)
}
}
export function getLog(): CommandEntry[] {
return entries
}
export function clearLog(): void {
entries.length = 0
}
+300
View File
@@ -0,0 +1,300 @@
// Redis 连接管理(支持 SSH 隧道)
import Redis, { type RedisOptions } from 'ioredis'
import { Client as SSHClient } from 'ssh2'
import { createServer, type AddressInfo } from 'net'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import os from 'os'
export interface ConnectionOptions {
host: string
port: number
username?: string
password?: string
tls?: boolean
tlsCaPath?: string
tlsCertPath?: string
tlsKeyPath?: string
tlsRejectUnauthorized?: boolean
sentinelEnabled?: boolean
sentinelHost?: string
sentinelPort?: number
sentinelMasterName?: string
sentinelNodePassword?: string
cluster?: boolean
sshEnabled?: boolean
sshHost?: string
sshPort?: number
sshUsername?: string
sshPassword?: string
sshPrivateKeyPath?: string
sshPassphrase?: string
}
export type RedisClient = Redis
interface ClientEntry {
client: RedisClient
sshClient?: SSHClient
tunnelServer?: ReturnType<typeof createServer>
}
const clients = new Map<string, ClientEntry>()
function expandHomePath(p: string): string {
if (p.startsWith('~')) {
return resolve(os.homedir(), p.slice(p.startsWith('~/') ? 2 : 1))
}
return resolve(p)
}
async function createSshTunnel(opts: ConnectionOptions): Promise<{ localPort: number; sshClient: SSHClient; server: ReturnType<typeof createServer> }> {
const sshConfig: any = {
host: opts.sshHost || opts.host,
port: opts.sshPort || 22,
username: opts.sshUsername || 'root',
readyTimeout: 10000,
keepaliveInterval: 10000,
}
if (opts.sshPrivateKeyPath) {
try {
sshConfig.privateKey = readFileSync(expandHomePath(opts.sshPrivateKeyPath))
if (opts.sshPassphrase) {
sshConfig.passphrase = opts.sshPassphrase
}
} catch {
// private key file not found, fall through to password auth
}
}
if (opts.sshPassword) {
sshConfig.password = opts.sshPassword
}
return new Promise((resolve, reject) => {
const sshClient = new SSHClient()
sshClient.on('ready', () => {
// Create a local TCP server to forward connections
const server = createServer((socket) => {
sshClient.forwardOut(
'127.0.0.1',
0,
opts.host,
opts.port,
(err, stream) => {
if (err) {
socket.destroy()
return
}
socket.pipe(stream).pipe(socket)
}
)
})
server.listen(0, '127.0.0.1', () => {
const localPort = (server.address() as AddressInfo).port
resolve({ localPort, sshClient, server })
})
server.on('error', (err) => {
sshClient.end()
reject(new Error(`SSH tunnel server error: ${err.message}`))
})
})
sshClient.on('error', (err: Error) => {
reject(new Error(`SSH connection failed: ${err.message}`))
})
sshClient.connect(sshConfig)
})
}
function buildRedisOptions(opts: ConnectionOptions): RedisOptions {
const options: RedisOptions = {
host: opts.host,
port: opts.port,
username: opts.username || undefined,
password: opts.password || undefined,
stringNumbers: true,
enableReadyCheck: true,
family: 0,
connectTimeout: 10000,
retryStrategy(times: number) {
if (times > 3) return null
return Math.min(times * 200, 1000)
},
maxRetriesPerRequest: 3,
}
if (opts.tls) {
const tlsOpts: any = {
rejectUnauthorized: opts.tlsRejectUnauthorized || false,
checkServerIdentity: () => undefined,
}
if (opts.tlsCaPath) {
try { tlsOpts.ca = readFileSync(expandHomePath(opts.tlsCaPath)) } catch { /* ignore */ }
}
if (opts.tlsCertPath) {
try { tlsOpts.cert = readFileSync(expandHomePath(opts.tlsCertPath)) } catch { /* ignore */ }
}
if (opts.tlsKeyPath) {
try { tlsOpts.key = readFileSync(expandHomePath(opts.tlsKeyPath)) } catch { /* ignore */ }
}
options.tls = tlsOpts
}
return options
}
export function getClient(id: string): RedisClient | undefined {
return clients.get(id)?.client
}
export function isClientConnected(id: string): boolean {
const entry = clients.get(id)
return entry?.client?.status === 'ready'
}
export async function connect(id: string, opts: ConnectionOptions): Promise<void> {
if (clients.has(id)) {
await disconnect(id)
}
return new Promise(async (resolve, reject) => {
let sshClient: SSHClient | undefined
let tunnelServer: ReturnType<typeof createServer> | undefined
try {
if (opts.sentinelEnabled && opts.sentinelMasterName) {
// Sentinel mode: connect via sentinel
const sentinelOptions: RedisOptions = {
sentinels: [{ host: opts.sentinelHost || opts.host, port: opts.sentinelPort || 26379 }],
name: opts.sentinelMasterName,
sentinelPassword: opts.sentinelNodePassword || undefined,
username: opts.username || undefined,
password: opts.password || undefined,
stringNumbers: true,
enableReadyCheck: true,
connectTimeout: 10000,
retryStrategy(times: number) {
if (times > 3) return null
return Math.min(times * 200, 1000)
},
maxRetriesPerRequest: 3,
}
if (opts.tls) {
sentinelOptions.tls = {
rejectUnauthorized: opts.tlsRejectUnauthorized || false,
checkServerIdentity: () => undefined,
}
}
const redis = new Redis(sentinelOptions)
redis.once('ready', () => {
clients.set(id, { client: redis })
resolve()
})
redis.once('error', (err: Error) => {
redis.disconnect()
reject(err)
})
setTimeout(() => {
if (!clients.has(id)) {
redis.disconnect()
reject(new Error('连接超时,请检查主机和端口'))
}
}, 12000)
return
}
if (opts.cluster) {
// Cluster mode
const clusterOptions: any = {
redisOptions: {
username: opts.username || undefined,
password: opts.password || undefined,
tls: opts.tls ? {
rejectUnauthorized: opts.tlsRejectUnauthorized || false,
checkServerIdentity: () => undefined,
} : undefined,
},
clusterRetryStrategy(times: number) {
if (times > 3) return null
return Math.min(times * 200, 1000)
},
enableReadyCheck: true,
}
const redis = new Redis.Cluster([{ host: opts.host, port: opts.port }], clusterOptions)
redis.once('ready', () => {
clients.set(id, { client: redis as any })
resolve()
})
redis.once('error', (err: Error) => {
redis.disconnect()
reject(err)
})
setTimeout(() => {
if (!clients.has(id)) {
redis.disconnect()
reject(new Error('连接超时,请检查主机和端口'))
}
}, 12000)
return
}
if (opts.sshEnabled) {
const tunnel = await createSshTunnel(opts)
sshClient = tunnel.sshClient
tunnelServer = tunnel.server
// Redirect Redis connection through SSH tunnel
opts = { ...opts, host: '127.0.0.1', port: tunnel.localPort }
}
const redisOptions = buildRedisOptions(opts)
const redis = new Redis(redisOptions)
redis.once('ready', () => {
clients.set(id, { client: redis, sshClient, tunnelServer })
resolve()
})
redis.once('error', (err: Error) => {
redis.disconnect()
if (sshClient) sshClient.end()
if (tunnelServer) tunnelServer.close()
reject(err)
})
setTimeout(() => {
if (!clients.has(id)) {
redis.disconnect()
if (sshClient) sshClient.end()
if (tunnelServer) tunnelServer.close()
reject(new Error('连接超时,请检查主机和端口'))
}
}, 12000)
} catch (err: any) {
reject(err)
}
})
}
export async function disconnect(id: string): Promise<void> {
const entry = clients.get(id)
if (entry) {
entry.client.disconnect()
if (entry.sshClient) entry.sshClient.end()
if (entry.tunnelServer) entry.tunnelServer.close()
clients.delete(id)
}
}
export function disconnectAll(): void {
for (const [id] of clients) {
disconnect(id)
}
}
+79
View File
@@ -0,0 +1,79 @@
// 值格式转换(解压/编码/自定义)
import { gunzipSync, inflateSync, brotliDecompressSync } from 'zlib'
import { execFile } from 'child_process'
import { writeFile, unlink, mkdtemp } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
export type FormatType = 'text' | 'hex' | 'json' | 'binary' | 'gzip' | 'deflate' | 'brotli' | 'deflateRaw'
export function decodeValue(value: string, format: FormatType): string {
const buf = Buffer.from(value, 'utf-8')
switch (format) {
case 'hex':
return buf.toString('hex').match(/.{1,2}/g)?.join(' ') ?? ''
case 'binary':
return buf.toString('binary')
case 'json':
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch {
return value
}
case 'gzip':
try { return gunzipSync(buf).toString('utf-8') } catch { return '[Gzip decode failed]' }
case 'deflate':
try { return inflateSync(buf).toString('utf-8') } catch { return '[Deflate decode failed]' }
case 'brotli':
try { return brotliDecompressSync(buf).toString('utf-8') } catch { return '[Brotli decode failed]' }
case 'deflateRaw':
try { return inflateSync(buf).toString('utf-8') } catch { return '[DeflateRaw decode failed]' }
default:
return value
}
}
export function detectFormat(value: string): FormatType {
if (!value) return 'text'
// JSON
try { JSON.parse(value); return 'json' } catch { /* not json */ }
// Gzip magic: 1f 8b
if (value.charCodeAt(0) === 0x1f && value.charCodeAt(1) === 0x8b) return 'gzip'
// Brotli magic (less reliable, check common patterns)
// Deflate (zlib) magic: 78 01/9c/da
if (value.charCodeAt(0) === 0x78 && [0x01, 0x9c, 0xda, 0x5e].includes(value.charCodeAt(1))) return 'deflate'
// Check for non-printable characters → binary/hex
const hasNonPrintable = /[\x00-\x08\x0e-\x1f]/.test(value.substring(0, 100))
if (hasNonPrintable) return 'hex'
return 'text'
}
export async function customFormat(
value: string,
command: string,
key?: string
): Promise<string> {
const tmpDir = await mkdtemp(join(tmpdir(), 'jrdm-fmt-'))
const tmpFile = join(tmpDir, 'value.bin')
await writeFile(tmpFile, value, 'utf-8')
return new Promise((resolve) => {
const cmd = command
.replace(/\{KEY\}/g, key || '')
.replace(/\{VALUE\}/g, value)
.replace(/\{HEX_FILE\}/g, tmpFile)
const parts = cmd.split(/\s+/)
const exe = parts[0]
const args = parts.slice(1)
execFile(exe, args, { timeout: 10000, maxBuffer: 10 * 1024 * 1024 }, async (err, stdout) => {
await unlink(tmpFile).catch(() => {})
if (err) {
resolve(`[Custom formatter error: ${err.message}]`)
} else {
resolve(stdout)
}
})
})
}
+29
View File
@@ -0,0 +1,29 @@
// Hash 操作
import { getClient } from './connection'
export async function getHashFields(
id: string, key: string, cursor: string, count: number
): Promise<{ cursor: string; fields: [string, string][] }> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
const [nextCursor, raw] = await client.hscan(key, cursor, 'COUNT', count)
const fields: [string, string][] = []
for (let i = 0; i < raw.length; i += 2) {
fields.push([raw[i], raw[i + 1]])
}
return { cursor: nextCursor, fields }
}
export async function setHashField(
id: string, key: string, field: string, value: string
): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.hset(key, field, value)
}
export async function deleteHashField(id: string, key: string, field: string): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.hdel(key, field)
}
+13
View File
@@ -0,0 +1,13 @@
// Redis 服务统一导出
export * from './connection'
export * from './keys'
export * from './string'
export * from './hash'
export * from './list'
export * from './set'
export * from './zset'
export * from './stream'
export * from './server'
export * from './commandLogger'
export * from './pubsub'
export * from './format'
+72
View File
@@ -0,0 +1,72 @@
// Key 操作
import { getClient } from './connection'
export async function getKeyType(id: string, key: string): Promise<string> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.type(key)
}
export async function getKeyTTL(id: string, key: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.ttl(key)
}
export async function deleteKey(id: string, key: string): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.del(key)
}
export async function setKeyTTL(id: string, key: string, ttl: number): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
if (ttl < 0) {
await client.persist(key)
} else {
await client.expire(key, ttl)
}
}
export async function renameKey(id: string, oldKey: string, newKey: string): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.rename(oldKey, newKey)
}
export async function existsKey(id: string, key: string): Promise<boolean> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return (await client.exists(key)) === 1
}
export async function batchDelete(id: string, keys: string[]): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
if (keys.length === 0) return 0
const pipeline = client.pipeline()
for (const key of keys) {
pipeline.del(key)
}
const results = await pipeline.exec()
return results?.filter(([err]) => !err).length ?? 0
}
export async function memoryUsage(id: string, key: string): Promise<number | null> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.memory('USAGE', key) as Promise<number | null>
}
export async function scanKeys(
id: string,
cursor: string,
pattern: string,
count: number
): Promise<{ cursor: string; keys: string[] }> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count)
return { cursor: nextCursor, keys }
}
+32
View File
@@ -0,0 +1,32 @@
// List 操作
import { getClient } from './connection'
export async function listRange(id: string, key: string, start: number, stop: number): Promise<string[]> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.lrange(key, start, stop)
}
export async function listLength(id: string, key: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.llen(key)
}
export async function listPush(id: string, key: string, ...values: string[]): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.rpush(key, ...values)
}
export async function listSet(id: string, key: string, index: number, value: string): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.lset(key, index, value)
}
export async function listRemove(id: string, key: string, count: number, value: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.lrem(key, count, value)
}
+78
View File
@@ -0,0 +1,78 @@
// 发布订阅 + MONITOR 流式接口
import Redis from 'ioredis'
import { getClient } from './connection'
import type { WebContents } from 'electron'
const subClients = new Map<string, Redis>()
const monitorClients = new Map<string, Redis>()
export function startSubscribe(
id: string,
channels: string[],
isPattern: boolean,
sender: WebContents
): void {
stopSubscribe(id)
const existing = getClient(id)
if (!existing) throw new Error('No active connection')
const sub = new Redis(existing.options)
subClients.set(id, sub)
if (isPattern) {
sub.psubscribe(...channels)
sub.on('pmessage', (_pattern, channel, message) => {
sender.send('redis:subscribeMessage', { channel, message, pattern: _pattern })
})
} else {
sub.subscribe(...channels)
sub.on('message', (channel, message) => {
sender.send('redis:subscribeMessage', { channel, message })
})
}
sub.on('error', (err) => {
sender.send('redis:subscribeMessage', { error: err.message })
})
}
export function stopSubscribe(id: string): void {
const sub = subClients.get(id)
if (sub) {
sub.unsubscribe()
sub.punsubscribe()
sub.disconnect()
subClients.delete(id)
}
}
export function startMonitor(id: string, sender: WebContents): void {
stopMonitor(id)
const existing = getClient(id)
if (!existing) throw new Error('No active connection')
const monitor = new Redis(existing.options)
monitorClients.set(id, monitor)
monitor.monitor().then((mon) => {
mon.on('monitor', (time: string, args: string[], source: string, database: number) => {
sender.send('redis:monitorMessage', { time, args, source, database })
})
mon.on('error', (err: Error) => {
sender.send('redis:monitorMessage', { error: err.message })
})
})
}
export function stopMonitor(id: string): void {
const mon = monitorClients.get(id)
if (mon) {
mon.disconnect()
monitorClients.delete(id)
}
}
export function stopAllPubSub(id: string): void {
stopSubscribe(id)
stopMonitor(id)
}
+44
View File
@@ -0,0 +1,44 @@
// 服务器操作
import { getClient } from './connection'
export async function execute(id: string, command: string, args: string[]): Promise<unknown> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.call(command, ...args)
}
export async function getServerInfo(id: string): Promise<string> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.info() as Promise<string>
}
export async function selectDb(id: string, db: number): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.select(db)
}
export async function dbSize(id: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.dbsize() as Promise<number>
}
export async function ping(id: string): Promise<string> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.ping()
}
export async function slowLogGet(id: string, count: number): Promise<any[]> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.slowlog('GET', count) as Promise<any[]>
}
export async function slowLogLen(id: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.slowlog('LEN') as Promise<number>
}
+26
View File
@@ -0,0 +1,26 @@
// Set 操作
import { getClient } from './connection'
export async function setMembers(id: string, key: string): Promise<string[]> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.smembers(key)
}
export async function setSize(id: string, key: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.scard(key)
}
export async function setAdd(id: string, key: string, ...members: string[]): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.sadd(key, ...members)
}
export async function setRemove(id: string, key: string, ...members: string[]): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.srem(key, ...members)
}
+71
View File
@@ -0,0 +1,71 @@
// Stream 操作
import { getClient } from './connection'
export async function streamInfo(id: string, key: string): Promise<any> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.xinfo('STREAM', key)
}
export async function streamRange(
id: string, key: string, start: string, end: string, count: number
): Promise<any[]> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.xrange(key, start, end, 'COUNT', count)
}
export async function streamAdd(
id: string, key: string, streamId: string, fieldValues: string[]
): Promise<string | null> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.xadd(key, streamId, ...fieldValues)
}
export async function streamTrim(id: string, key: string, maxLen: number): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.xtrim(key, 'MAXLEN', maxLen)
}
export async function streamDel(id: string, key: string, ...ids: string[]): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.xdel(key, ...ids)
}
// Consumer group operations
export async function streamGroups(id: string, key: string): Promise<any[]> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.xinfo('GROUPS', key) as Promise<any[]>
}
export async function streamConsumers(id: string, key: string, group: string): Promise<any[]> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.xinfo('CONSUMERS', key, group) as Promise<any[]>
}
export async function streamGroupCreate(
id: string, key: string, group: string, startId: string, mkstream: boolean
): Promise<string> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
const args: any[] = [key, group, startId]
if (mkstream) args.push('MKSTREAM')
return (client as any).xgroup('CREATE', ...args)
}
export async function streamGroupDestroy(id: string, key: string, group: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return (client as any).xgroup('DESTROY', key, group)
}
export async function streamAck(id: string, key: string, group: string, ...ids: string[]): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return (client as any).xack(key, group, ...ids)
}
+33
View File
@@ -0,0 +1,33 @@
// String + ReJson 操作
import { getClient } from './connection'
export async function getStringValue(id: string, key: string): Promise<string | null> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.get(key)
}
export async function setStringValue(id: string, key: string, value: string): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
await client.set(key, value)
}
// ReJson operations
export async function rejsonGet(id: string, key: string, path: string = '.'): Promise<string> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return (client as any).call('JSON.GET', key, path)
}
export async function rejsonSet(id: string, key: string, path: string, value: string): Promise<void> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
await (client as any).call('JSON.SET', key, path, value)
}
export async function rejsonDel(id: string, key: string, path: string = '.'): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return (client as any).call('JSON.DEL', key, path)
}
+27
View File
@@ -0,0 +1,27 @@
// Sorted Set 操作
import { getClient } from './connection'
export async function zsetRange(id: string, key: string, start: number, stop: number, withScores: boolean): Promise<string[]> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
if (withScores) return client.zrange(key, start, stop, 'WITHSCORES')
return client.zrange(key, start, stop)
}
export async function zsetSize(id: string, key: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.zcard(key)
}
export async function zsetAdd(id: string, key: string, score: number, member: string): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.zadd(key, score, member)
}
export async function zsetRemove(id: string, key: string, ...members: string[]): Promise<number> {
const client = getClient(id)
if (!client) throw new Error(`Client ${id} not found`)
return client.zrem(key, ...members)
}
+60
View File
@@ -0,0 +1,60 @@
import Store from 'electron-store'
interface ConnectionConfig {
id: string
name: string
host: string
port: number
auth: string
username: string
tls: boolean
tlsCaPath: string
tlsCertPath: string
tlsKeyPath: string
tlsRejectUnauthorized: boolean
sshEnabled: boolean
sshHost: string
sshPort: number
sshUsername: string
sshPassword: string
sshPrivateKeyPath: string
sshPassphrase: string
cluster: boolean
sentinelEnabled: boolean
sentinelHost: string
sentinelPort: number
sentinelMasterName: string
sentinelNodePassword: string
separator: string
order: number
createdAt: number
color?: string
}
interface AppSettings {
theme: 'system' | 'dark' | 'light'
locale: string
fontSize: number
scanCount: number
}
interface AppStorage {
connections: ConnectionConfig[]
settings: AppSettings
}
const store = new Store<AppStorage>({
name: 'jrdm-config',
defaults: {
connections: [],
settings: {
theme: 'system',
locale: 'en',
fontSize: 13,
scanCount: 500,
},
},
})
export default store
export type { ConnectionConfig, AppSettings }
+74
View File
@@ -0,0 +1,74 @@
// electron-updater is CommonJS with a lazy getter for `autoUpdater`, so a named
// ESM import fails at runtime under Node ESM (cjs-module-lexer can't detect it).
// Use the default interop import and access the instance lazily inside initUpdater
// (after app.whenReady), matching the original intended evaluation timing.
import electronUpdater from 'electron-updater'
import { BrowserWindow, ipcMain } from 'electron'
let mainWindow: BrowserWindow | null = null
export function initUpdater(win: BrowserWindow): void {
mainWindow = win
const autoUpdater = electronUpdater.autoUpdater
// 检查更新
ipcMain.handle('updater:check', async () => {
try {
const result = await autoUpdater.checkForUpdates()
return result?.updateInfo ? { available: true, version: result.updateInfo.version } : { available: false }
} catch {
return { available: false }
}
})
// 下载更新
ipcMain.handle('updater:download', async () => {
try {
await autoUpdater.downloadUpdate()
return { success: true }
} catch (err: any) {
return { success: false, error: err.message }
}
})
// 安装更新
ipcMain.handle('updater:install', () => {
autoUpdater.quitAndInstall()
})
autoUpdater.autoDownload = false
autoUpdater.on('checking-for-update', () => {
mainWindow?.webContents.send('updater:status', { status: 'checking' })
})
autoUpdater.on('update-available', (info) => {
mainWindow?.webContents.send('updater:status', {
status: 'available',
version: info.version,
releaseNotes: info.releaseNotes,
})
})
autoUpdater.on('update-not-available', () => {
mainWindow?.webContents.send('updater:status', { status: 'not-available' })
})
autoUpdater.on('download-progress', (progress) => {
mainWindow?.webContents.send('updater:status', {
status: 'downloading',
percent: progress.percent,
transferred: progress.transferred,
total: progress.total,
bytesPerSecond: progress.bytesPerSecond,
})
})
autoUpdater.on('update-downloaded', () => {
mainWindow?.webContents.send('updater:status', { status: 'downloaded' })
})
autoUpdater.on('error', (err) => {
mainWindow?.webContents.send('updater:status', { status: 'error', message: err.message })
})
}
+59
View File
@@ -0,0 +1,59 @@
import { app, screen, BrowserWindow } from 'electron'
import { join } from 'path'
import { readFileSync, writeFile } from 'fs'
interface WinState {
x: number | null
y: number | null
width: number
height: number
maximized: boolean
}
const STATE_FILE = join(app.getPath('userData'), 'jrdm-win-state.json')
const MIN_WIDTH = 900
const MIN_HEIGHT = 600
const DEFAULT_WIDTH = 1200
const DEFAULT_HEIGHT = 800
function parseJson(str: string): WinState | null {
try { return JSON.parse(str) }
catch { return null }
}
function getLastState(): WinState {
try {
const raw = readFileSync(STATE_FILE, 'utf-8')
const state = parseJson(raw)
if (state) {
const display = screen.getPrimaryDisplay().workArea
if (state.x != null && (state.x < 0 || state.x > display.width - 100)) {
state.x = null; state.y = null
}
if (state.y != null && (state.y < 0 || state.y > display.height - 100)) {
state.x = null; state.y = null
}
state.width = Math.max(state.width || DEFAULT_WIDTH, MIN_WIDTH)
state.height = Math.max(state.height || DEFAULT_HEIGHT, MIN_HEIGHT)
return state
}
} catch { /* file doesn't exist yet */ }
return { x: null, y: null, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, maximized: false }
}
function saveState(win: BrowserWindow): void {
const bounds = win.getBounds()
const state = { ...bounds, maximized: win.isMaximized() }
writeFile(STATE_FILE, JSON.stringify(state), 'utf-8', () => {})
}
export function restoreAndTrack(win: BrowserWindow): void {
const lastState = getLastState()
if (lastState.x != null && lastState.y != null) {
win.setBounds({ x: lastState.x, y: lastState.y, width: lastState.width, height: lastState.height })
}
if (lastState.maximized) {
win.maximize()
}
win.on('close', () => saveState(win))
}