Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 88 additions & 25 deletions README.md

Large diffs are not rendered by default.

361 changes: 177 additions & 184 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "mongobench",
"version": "1.5.0",
"version": "1.6.0",
"private": true,
"description": "A modern, dark-mode-first MongoDB GUI.",
"author": "ByteExceptionM",
Expand Down Expand Up @@ -85,7 +85,7 @@
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2",
"vite": "^7.0.0",
"vitest": "^4.0.0"
"vitest": "^4.1.12"
},
"overrides": {
"dompurify": "^3.4.13"
Expand Down
24 changes: 19 additions & 5 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const APP_ICON = app.isPackaged
import log from 'electron-log/main'
import { EventChannels } from './ipc/channels'
import { registerIpcHandlers } from './ipc/router'
import { opensInBrowser } from './lib/externalUrl'
import { CloudflaredTunnelService } from './services/CloudflaredTunnelService'
import { ConnectionService } from './services/ConnectionService'
import { DatabaseService } from './services/DatabaseService'
import { IndexService } from './services/IndexService'
Expand Down Expand Up @@ -71,14 +73,14 @@ function createWindow(): void {
})

window.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url)
if (opensInBrowser(url)) void shell.openExternal(url)
return { action: 'deny' }
})

window.webContents.on('will-navigate', (event, url) => {
if (url !== window.webContents.getURL()) {
event.preventDefault()
void shell.openExternal(url)
if (opensInBrowser(url)) void shell.openExternal(url)
}
})

Expand Down Expand Up @@ -113,8 +115,11 @@ function createWindow(): void {
app.whenReady().then(() => {
const secrets = new SecretsStore()
const repo = new ConnectionsRepository(secrets)
const tunnels = new SshTunnelService(new HostKeysStore())
const connections = new ConnectionService(repo, tunnels, (connectionId, reason) =>
const sshTunnels = new SshTunnelService(new HostKeysStore())
const cloudflared = new CloudflaredTunnelService((prompt) =>
pushToRenderer(EventChannels.CloudflaredLogin, prompt)
)
const connections = new ConnectionService(repo, sshTunnels, cloudflared, (connectionId, reason) =>
pushToRenderer(EventChannels.ConnectionDropped, { connectionId, reason })
)
const databases = new DatabaseService(connections)
Expand All @@ -127,7 +132,16 @@ app.whenReady().then(() => {
services.repo = repo
services.connections = connections

registerIpcHandlers({ repo, connections, databases, queries, users, indexes, updater })
registerIpcHandlers({
repo,
connections,
databases,
queries,
users,
indexes,
updater,
cloudflared
})

createWindow()

Expand Down
14 changes: 12 additions & 2 deletions src/main/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@ export const Channels = {
CollectionsCreate: 'collections:create',
CollectionsDrop: 'collections:drop',
CollectionsRename: 'collections:rename',
CollectionsSchema: 'collections:schema',

IndexesList: 'indexes:list',
IndexesCreate: 'indexes:create',
IndexesDrop: 'indexes:drop',
IndexesStats: 'indexes:stats',

UsersList: 'users:list',
UsersCreate: 'users:create',
UsersUpdate: 'users:update',
UsersDrop: 'users:drop',
RolesList: 'roles:list',

QueryFind: 'query:find',
QueryAggregate: 'query:aggregate',
Expand All @@ -40,18 +43,25 @@ export const Channels = {
QueryInsertMany: 'query:insertMany',
QueryDeleteOne: 'query:deleteOne',
QueryDeleteMany: 'query:deleteMany',
QueryDistinct: 'query:distinct',
QueryCancel: 'query:cancel',
QueryWrite: 'query:write',

UpdaterCheck: 'updater:check',
UpdaterDownload: 'updater:download',
UpdaterInstall: 'updater:install',

DialogPickPrivateKey: 'dialog:pickPrivateKey'
CloudflaredVersion: 'cloudflared:version',

DialogPickPrivateKey: 'dialog:pickPrivateKey',
DialogPickCloudflared: 'dialog:pickCloudflared'
} as const

export type ChannelName = (typeof Channels)[keyof typeof Channels]

/** main → renderer pushes. Subscribed to in the preload, never `handle`d. */
export const EventChannels = {
UpdaterProgress: 'updater:progress',
ConnectionDropped: 'connections:dropped'
ConnectionDropped: 'connections:dropped',
CloudflaredLogin: 'cloudflared:login'
} as const
78 changes: 78 additions & 0 deletions src/main/ipc/router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Result } from '@shared/result'

type Handler = (event: unknown, payload?: unknown) => Promise<Result<unknown>>

const ipc = vi.hoisted(() => ({ handlers: new Map<string, Handler>(), warn: vi.fn() }))

vi.mock('electron', () => ({
BrowserWindow: { getFocusedWindow: () => null },
dialog: { showOpenDialog: vi.fn() },
ipcMain: { handle: (channel: string, handler: Handler) => ipc.handlers.set(channel, handler) }
}))
vi.mock('electron-log/main', () => ({
default: { info: vi.fn(), warn: ipc.warn, debug: vi.fn(), error: vi.fn() }
}))

const { registerIpcHandlers } = await import('./router')
const { Channels } = await import('./channels')
type Services = Parameters<typeof registerIpcHandlers>[0]

const FIND = {
connectionId: '7f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f',
db: 'app',
coll: 'players',
skip: 0
}

const queries = { find: vi.fn() }
const repo = { list: vi.fn() }
registerIpcHandlers({ queries, repo } as unknown as Services)

function invoke(channel: string, payload?: unknown): Promise<Result<unknown>> {
const handler = ipc.handlers.get(channel)
if (!handler) throw new Error(`no handler for ${channel}`)
return handler({}, payload)
}

function named(name: string, message: string): Error {
return Object.assign(new Error(message), { name })
}

beforeEach(() => {
ipc.warn.mockClear()
})

describe('IPC handler failures', () => {
it('returns a cancelled run as an error without logging a warning', async () => {
queries.find.mockRejectedValueOnce(named('CancelledError', 'Query cancelled'))

await expect(invoke(Channels.QueryFind, FIND)).resolves.toEqual({
ok: false,
error: { code: 'cancelled', message: 'Query cancelled' }
})
expect(ipc.warn).not.toHaveBeenCalled()
})

it('logs every other failure as a warning', async () => {
queries.find.mockRejectedValueOnce(named('DocumentNotFoundError', 'gone'))

await expect(invoke(Channels.QueryFind, FIND)).resolves.toMatchObject({
ok: false,
error: { code: 'not_found' }
})
expect(ipc.warn).toHaveBeenCalledWith('IPC handler failed', {
code: 'not_found',
message: 'gone'
})
})

it('treats a cancel the same way in handlers without input', async () => {
repo.list.mockRejectedValueOnce(named('CancelledError', 'Query cancelled'))

await expect(invoke(Channels.ConnectionsList)).resolves.toMatchObject({
error: { code: 'cancelled' }
})
expect(ipc.warn).not.toHaveBeenCalled()
})
})
104 changes: 85 additions & 19 deletions src/main/ipc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import log from 'electron-log/main'
import type { ZodType } from 'zod'
import {
AggregateRequestSchema,
CancelQuerySchema,
CloudflaredVersionSchema,
CollectionRefSchema,
ConnectionIdSchema,
ConnectionInputSchema,
Expand All @@ -19,6 +21,7 @@ import {
DatabaseRefSchema,
DeleteManyRequestSchema,
DeleteOneRequestSchema,
DistinctRequestSchema,
DropIndexSchema,
DropUserSchema,
FindRequestSchema,
Expand All @@ -28,12 +31,14 @@ import {
RenameCollectionSchema,
ReorderConnectionsSchema,
ReplaceOneRequestSchema,
UpdateUserSchema
UpdateUserSchema,
WriteRequestSchema
} from '@shared/schemas'
import type { Result } from '@shared/result'
import { err, ok } from '@shared/result'
import { mapError } from '../lib/errorMap'
import type { ConnectionsRepository } from '../stores/ConnectionsRepository'
import type { CloudflaredTunnelService } from '../services/CloudflaredTunnelService'
import type { ConnectionService } from '../services/ConnectionService'
import type { DatabaseService } from '../services/DatabaseService'
import type { IndexService } from '../services/IndexService'
Expand All @@ -50,6 +55,7 @@ export type Services = {
users: UserService
indexes: IndexService
updater: UpdaterService
cloudflared: CloudflaredTunnelService
}

function withResult<P, R>(
Expand All @@ -66,9 +72,7 @@ function withResult<P, R>(
try {
return ok(await fn(parsed.data))
} catch (error) {
const mapped = mapError(error)
log.warn(`IPC handler failed`, mapped)
return err(mapped.code, mapped.message, mapped.details)
return failure(error)
}
}
}
Expand All @@ -78,15 +82,31 @@ function withoutInput<R>(fn: () => Promise<R>): (event: IpcMainInvokeEvent) => P
try {
return ok(await fn())
} catch (error) {
const mapped = mapError(error)
log.warn(`IPC handler failed`, mapped)
return err(mapped.code, mapped.message, mapped.details)
return failure(error)
}
}
}

function failure(error: unknown): Result<never> {
const mapped = mapError(error)
// A cancel is the user's own action, not a failure.
if (mapped.code !== 'cancelled') log.warn(`IPC handler failed`, mapped)
return err(mapped.code, mapped.message, mapped.details)
}

/** Native file picker, parented to the focused window when there is one. */
async function pickFile(options: Electron.OpenDialogOptions): Promise<string | null> {
const parent = BrowserWindow.getFocusedWindow()
const result =
parent === null
? await dialog.showOpenDialog(options)
: await dialog.showOpenDialog(parent, options)
if (result.canceled) return null
return result.filePaths[0] ?? null
}

export function registerIpcHandlers(services: Services): void {
const { repo, connections, databases, queries, users, indexes, updater } = services
const { repo, connections, databases, queries, users, indexes, updater, cloudflared } = services

ipcMain.handle(
Channels.ConnectionsList,
Expand Down Expand Up @@ -166,6 +186,13 @@ export function registerIpcHandlers(services: Services): void {
)
)

ipcMain.handle(
Channels.CollectionsSchema,
withResult(CollectionRefSchema, ({ connectionId, db, coll }) =>
databases.collectionSchema(connectionId, db, coll)
)
)

ipcMain.handle(
Channels.CollectionsCreate,
withResult(CreateCollectionSchema, ({ connectionId, db, name }) =>
Expand Down Expand Up @@ -232,13 +259,36 @@ export function registerIpcHandlers(services: Services): void {
withResult(DeleteManyRequestSchema, (request) => queries.deleteMany(request))
)

ipcMain.handle(
Channels.QueryDistinct,
withResult(DistinctRequestSchema, (request) => queries.distinct(request))
)

// Ids of runs that already settled are ignored.
ipcMain.handle(
Channels.QueryCancel,
withResult(CancelQuerySchema, async ({ requestId }) => queries.cancel(requestId))
)

ipcMain.handle(
Channels.QueryWrite,
withResult(WriteRequestSchema, (request) => queries.write(request))
)

ipcMain.handle(
Channels.IndexesList,
withResult(IndexesListSchema, ({ connectionId, db, coll }) =>
indexes.listIndexes(connectionId, db, coll)
)
)

ipcMain.handle(
Channels.IndexesStats,
withResult(CollectionRefSchema, ({ connectionId, db, coll }) =>
indexes.indexStats(connectionId, db, coll)
)
)

ipcMain.handle(
Channels.IndexesCreate,
withResult(CreateIndexSchema, ({ connectionId, db, coll, keys, options }) =>
Expand Down Expand Up @@ -279,6 +329,16 @@ export function registerIpcHandlers(services: Services): void {
)
)

ipcMain.handle(
Channels.RolesList,
withResult(DatabaseRefSchema, ({ connectionId, db }) => users.listRoles(connectionId, db))
)

ipcMain.handle(
Channels.CloudflaredVersion,
withResult(CloudflaredVersionSchema, ({ binaryPath }) => cloudflared.version(binaryPath))
)

ipcMain.handle(
Channels.UpdaterCheck,
withoutInput(() => updater.check())
Expand All @@ -302,20 +362,26 @@ export function registerIpcHandlers(services: Services): void {
// main at connect time and never leaves it.
ipcMain.handle(
Channels.DialogPickPrivateKey,
withoutInput(async () => {
const options: Electron.OpenDialogOptions = {
withoutInput(() =>
pickFile({
title: 'Select an SSH private key',
defaultPath: join(homedir(), '.ssh'),
// Key files carry no extension, and .ssh is a hidden directory.
properties: ['openFile', 'showHiddenFiles', 'dontAddToRecent']
}
const parent = BrowserWindow.getFocusedWindow()
const result =
parent === null
? await dialog.showOpenDialog(options)
: await dialog.showOpenDialog(parent, options)
if (result.canceled) return null
return result.filePaths[0] ?? null
})
})
)
)

ipcMain.handle(
Channels.DialogPickCloudflared,
withoutInput(() =>
pickFile({
title: 'Select the cloudflared executable',
properties: ['openFile', 'dontAddToRecent'],
...(process.platform === 'win32'
? { filters: [{ name: 'Executables', extensions: ['exe'] }] }
: {})
})
)
)
}
Loading
Loading