Skip to content
Open
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
1 change: 0 additions & 1 deletion .python-version

This file was deleted.

4,158 changes: 2,121 additions & 2,037 deletions frontend/pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions frontend/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { registerIpc } from './ipc'
import openInspector from './utils/inspector'
import db from './services/DatabaseService'
import screenshotService from './services/ScreenshotService'
import trayService from './services/TrayService'
import { isDev, isMac } from './constant'
import icon from '../../resources/icon.png?asset'
import { ensureBackendRunning, startBackendInBackground, stopBackendServerSync } from './backend'
Expand Down Expand Up @@ -213,6 +214,9 @@ app.whenReady().then(() => {
powerWatcher.run(mainWindow)
startBackendInBackground(mainWindow)

// Initialize tray service
trayService.initialize(mainWindow)

// Start screenshot cleanup scheduled task
startScreenshotCleanup()

Expand Down
11 changes: 11 additions & 0 deletions frontend/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { localStoreService } from './services/LocalStoreService'
import { activityService } from './services/ActivityService'
import { IpcServerPushChannel } from '@shared/ipc-server-push-channel'
import { VaultDocumentType } from '@shared/enums/global-enum'
import trayService from './services/TrayService'

const logger = getLogger('IPC')

Expand Down Expand Up @@ -538,4 +539,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
return localStoreService.setSetting(key, value)
})
ipcMain.handle(IpcChannel.Screen_Monitor_Clear_Settings, (_, key: string) => localStoreService.clearSetting(key))

// Tray service
ipcMain.handle(IpcChannel.App_SetTray, (_, isRecording: boolean = false) => {
trayService.setRecording(isRecording)
})

ipcMain.handle(IpcChannel.App_SetTrayOnClose, () => {
// The tray is already initialized in index.ts.
// Extend here later for custom close-to-tray behavior if needed.
})
}
173 changes: 173 additions & 0 deletions frontend/src/main/services/TrayService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd.
// SPDX-License-Identifier: Apache-2.0

import { app, BrowserWindow, Menu, Tray, nativeImage } from 'electron'
import path from 'path'
import { getLogger } from '@shared/logger/main'
import { isMac, isWin } from '@main/constant'
import { getResourcePath } from '@main/utils'

const logger = getLogger('TrayService')

export class TrayService {
private static instance: TrayService
private tray: Tray | null = null
private mainWindow: BrowserWindow | null = null

private constructor() {
// Private constructor to prevent direct instantiation
}

public static getInstance(): TrayService {
if (!TrayService.instance) {
TrayService.instance = new TrayService()
}
return TrayService.instance
}

/**
* Initialize the tray icon
*/
public initialize(mainWindow: BrowserWindow): void {
this.mainWindow = mainWindow
this.createTray()
}

/**
* Create the tray icon and menu
*/
private createTray(): void {
try {
// Get the icon path
const iconPath = this.getIconPath()
if (!iconPath) {
logger.error('Failed to get tray icon path')
return
}

const icon = nativeImage.createFromPath(iconPath)
if (icon.isEmpty()) {
logger.error('Failed to create tray icon from path:', iconPath)
return
}

// Resize icon for tray (tray icons should be small)
const trayIcon = icon.resize({ width: 16, height: 16 })
this.tray = new Tray(trayIcon)

// Set tooltip
this.tray.setToolTip('MineContext')

// Create and set context menu
this.updateMenu()

// Handle tray click
this.tray.on('click', () => {
if (this.mainWindow) {
if (this.mainWindow.isVisible()) {
this.mainWindow.hide()
} else {
this.mainWindow.show()
this.mainWindow.focus()
}
}
})

logger.info('Tray icon created successfully')
} catch (error) {
logger.error('Failed to create tray icon:', error)
}
}

/**
* Get the icon path for the tray
*/
private getIconPath(): string | null {
try {
const resourcePath = getResourcePath()
if (isMac) {
return path.join(resourcePath, 'icon.png')
} else if (isWin) {
return path.join(resourcePath, 'icon.png')
}
return null
} catch (error) {
logger.error('Failed to get icon path:', error)
return null
}
}

/**
* Update the tray menu with current state
*/
public updateMenu(isRecording: boolean = false): void {

if (!this.tray) {
return
}

const template: Electron.MenuItemConstructorOptions[] = [
{
label: isRecording ? 'Recording' : '',
enabled: false,
visible: isRecording
},
{
type: 'separator',
visible: isRecording
},
{
label: 'Show Main Window',
click: () => {
if (this.mainWindow) {
this.mainWindow.show()
this.mainWindow.focus()
}
}
},
{
label: isRecording ? 'Pause Recording' : '',
click: () => {
// This will be handled by the renderer process
if (this.mainWindow) {
this.mainWindow.webContents.send('tray-pause-recording')
}
},
visible: isRecording
},
{
type: 'separator'
},
{
label: 'Quit MineContext',
click: () => {
app.quit()
}
}
]

const contextMenu = Menu.buildFromTemplate(template)
this.tray.setContextMenu(contextMenu)
}

/**
* Set recording state and update menu
*/
public setRecording(isRecording: boolean): void {
this.updateMenu(isRecording)
}

/**
* Destroy the tray icon
*/
public destroy(): void {
if (this.tray) {
this.tray.destroy()
this.tray = null
}
this.mainWindow = null
}
}

export default TrayService.getInstance()

4 changes: 4 additions & 0 deletions frontend/src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,9 @@ declare global {
copyFile: (srcPath: string) => Promise<any>
getFiles: () => Promise<any>
}
appAPI: {
setTray: (isRecording: boolean) => Promise<void>
setTrayOnClose: (enabled: boolean) => Promise<void>
}
}
}
8 changes: 8 additions & 0 deletions frontend/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ const fileService = {
getFiles: () => ipcRenderer.invoke(IpcChannel.File_Get_All)
}

const appAPI = {
setTray: (isRecording: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTray, isRecording),
setTrayOnClose: (enabled: boolean) => ipcRenderer.invoke(IpcChannel.App_SetTrayOnClose, enabled)
}

// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
Expand All @@ -109,6 +114,7 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('screenMonitorAPI', screenMonitorAPI)
contextBridge.exposeInMainWorld('fileService', fileService)
contextBridge.exposeInMainWorld('serverPushAPI', serverPushAPI)
contextBridge.exposeInMainWorld('appAPI', appAPI)
} catch (error) {
console.error(error)
}
Expand All @@ -125,6 +131,8 @@ if (process.contextIsolated) {
window.fileService = fileService
// @ts-ignore (define in dts)
window.serverPushAPI = serverPushAPI
// @ts-ignore (define in dts)
window.appAPI = appAPI
}

ipcRenderer.on('main-log', (_, ...args) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ const ScreenMonitor: React.FC = () => {
startCapture()
// Start polling for new activities
startActivityPolling()
// Update tray icon
window.appAPI?.setTray(true)
}

// Stop monitoring
Expand All @@ -172,6 +174,8 @@ const ScreenMonitor: React.FC = () => {
// Stop polling for new activities
stopActivityPolling()
clearCache()
// Update tray icon
window.appAPI?.setTray(false)
}
}

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/renderer/src/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ interface dbAPI {
getAllVaults: () => Promise<Vault[]>
[propName: string]: (...args: any[]) => any
}
interface AppAPIType {
setTray: (isRecording: boolean) => Promise<void>
setTrayOnClose: (enabled: boolean) => Promise<void>
}

declare global {
interface Window {
Expand All @@ -72,5 +76,6 @@ declare global {
screenMonitorAPI: ScreenMonitorAPI
fileService: any
serverPushAPI: any
appAPI: AppAPI
}
}
31 changes: 24 additions & 7 deletions opencontext.spec
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
# -*- mode: python ; coding: utf-8 -*-
import sys
from PyInstaller.utils.hooks import collect_submodules, collect_data_files

is_windows = sys.platform.startswith("win")

_numpy_hidden = collect_submodules('numpy')
_pandas_hidden = collect_submodules('pandas')
_numpy_datas = collect_data_files('numpy')
_pandas_datas = collect_data_files('pandas')

# ASGI/uvicorn family
_uvicorn_hidden = collect_submodules('uvicorn')
_starlette_hidden = collect_submodules('starlette')
_fastapi_hidden = collect_submodules('fastapi')
_anyio_hidden = collect_submodules('anyio')
_h11_hidden = collect_submodules('h11')
_websockets_hidden = collect_submodules('websockets')

a = Analysis(
['opencontext/cli.py'],
pathex=[],
binaries=[],
datas=[
('config/config.yaml', 'config'),
('opencontext/web/static', 'opencontext/web/static'),
('opencontext/web/templates', 'opencontext/web/templates')
],
('config/config.yaml', 'config'),
('opencontext/web/static', 'opencontext/web/static'),
('opencontext/web/templates', 'opencontext/web/templates'),
] + _numpy_datas + _pandas_datas,
hiddenimports=[
'uvicorn.protocols.http.auto',
'uvicorn',
'uvicorn.config',
'uvicorn.server',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.websockets.auto',
'chromadb.telemetry.product.posthog',
'chromadb.api.rust',
Expand All @@ -23,12 +40,12 @@ a = Analysis(
'chromadb.segment.impl.metadata.sqlite',
'hnswlib',
'sqlite3',
],
] + _numpy_hidden + _pandas_hidden + _uvicorn_hidden + _starlette_hidden + _fastapi_hidden + _anyio_hidden + _h11_hidden + _websockets_hidden,
hookspath=['.'],
hooksconfig={},
runtime_hooks=['hook-opencontext.py'],
excludes=[],
noarchive=True,
noarchive=False, # was True – this avoids importing numpy from a “source-like” dir
optimize=1,
)
pyz = PYZ(a.pure)
Expand Down