diff --git a/.changeset/refresh-batch-import.md b/.changeset/refresh-batch-import.md new file mode 100644 index 00000000..992ad2c1 --- /dev/null +++ b/.changeset/refresh-batch-import.md @@ -0,0 +1,9 @@ +--- +"@promptx/desktop": patch +--- + +feat: add refresh button and batch import functionality + +- Add refresh button to reload resource list +- Add batch import feature for importing multiple resources at once +- Improve resource management user experience diff --git a/.changeset/silent-sounds-fix.md b/.changeset/silent-sounds-fix.md new file mode 100644 index 00000000..277743e5 --- /dev/null +++ b/.changeset/silent-sounds-fix.md @@ -0,0 +1,11 @@ +--- +"@promptx/desktop": patch +--- + +fix: disable notification sounds on macOS startup (#493) + +- Set notification adapter to silent by default to prevent system sounds on app launch +- Add autoplayPolicy to BrowserWindow webPreferences to prevent media autoplay +- Fix issue where macOS played notification sound every time the app started + +This change improves the user experience by making notifications silent by default, following desktop application best practices. Users can still see notifications, but without the disruptive sound effects. diff --git a/.changeset/single-instance-ux.md b/.changeset/single-instance-ux.md new file mode 100644 index 00000000..5cf7ca99 --- /dev/null +++ b/.changeset/single-instance-ux.md @@ -0,0 +1,13 @@ +--- +'@promptx/desktop': patch +'@promptx/logger': patch +--- + +feat: single instance lock and UX improvements + +- Add single instance lock to prevent multiple app instances +- Auto open main window on startup for better UX +- Focus existing window when user clicks shortcut while app is running +- Add resource type validation framework for import +- Fix logger file lock issue with graceful fallback to console +- Fix logs list refresh after clearing all logs diff --git a/apps/desktop/src/main/bootstrap.ts b/apps/desktop/src/main/bootstrap.ts index 426b375a..c3186cba 100644 --- a/apps/desktop/src/main/bootstrap.ts +++ b/apps/desktop/src/main/bootstrap.ts @@ -57,5 +57,44 @@ if (typeof globalThis.File === 'undefined') { } } -// Now import the actual app -import('./index.js') \ No newline at end of file +// ==================== Single Instance Lock ==================== +// Check BEFORE importing main app to avoid logger file lock conflicts +import { app, BrowserWindow } from 'electron' + +// Global callback for opening main window (set by main app after initialization) +;(global as any).__promptxOpenMainWindow = null + +const gotTheLock = app.requestSingleInstanceLock() + +if (!gotTheLock) { + // Another instance is already running, quit immediately + console.log('Another instance is already running. Quitting...') + app.quit() +} else { + // Set up second-instance handler IMMEDIATELY after getting lock + // This ensures we catch second instance attempts even during app initialization + app.on('second-instance', () => { + console.log('Second instance detected, focusing existing window...') + + // Focus existing window if any + const windows = BrowserWindow.getAllWindows() + if (windows.length > 0) { + const mainWindow = windows[0] + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + mainWindow.show() + mainWindow.focus() + } else { + // No window exists, use callback to open main window + const openMainWindow = (global as any).__promptxOpenMainWindow + if (typeof openMainWindow === 'function') { + openMainWindow() + } + } + }) + + // Now import the actual app (only if we got the lock) + import('./index.js') +} +// ============================================================== \ No newline at end of file diff --git a/apps/desktop/src/main/i18n/en.json b/apps/desktop/src/main/i18n/en.json index faa3af1b..ccba6515 100644 --- a/apps/desktop/src/main/i18n/en.json +++ b/apps/desktop/src/main/i18n/en.json @@ -1,4 +1,7 @@ { + "app": { + "alreadyRunning": "PromptX is already running in the system tray" + }, "tray": { "tooltip": "PromptX", "status": { @@ -75,7 +78,16 @@ "importFailed": "Failed to import resource", "invalidResourceStructure": "Invalid resource structure: cannot find .role.md or .tool.js file", "resourceExists": "Resource Already Exists", - "resourceExistsMessage": "Resource \"{{id}}\" already exists. Do you want to overwrite it?" + "resourceExistsMessage": "Resource \"{{id}}\" already exists. Do you want to overwrite it?", + "import": { + "errors": { + "invalidRoleType": "Invalid role resource: no .md file found in the archive", + "invalidToolType": "Invalid tool resource: no .js file found in the archive", + "mismatchRoleExpectedTool": "Type mismatch: selected 'role' but the archive contains a tool resource", + "mismatchToolExpectedRole": "Type mismatch: selected 'tool' but the archive contains a role resource", + "validationFailed": "Failed to validate resource type" + } + } }, "errors": { "configLoadFailed": "Configuration file exists but failed to load", diff --git a/apps/desktop/src/main/i18n/zh-CN.json b/apps/desktop/src/main/i18n/zh-CN.json index 0d98598f..a6837de9 100644 --- a/apps/desktop/src/main/i18n/zh-CN.json +++ b/apps/desktop/src/main/i18n/zh-CN.json @@ -1,4 +1,7 @@ { + "app": { + "alreadyRunning": "PromptX 已在系统托盘中运行" + }, "tray": { "tooltip": "PromptX", "status": { @@ -75,7 +78,16 @@ "importFailed": "导入资源失败", "invalidResourceStructure": "无效的资源结构:找不到 .role.md 或 .tool.js 文件", "resourceExists": "资源已存在", - "resourceExistsMessage": "资源 \"{{id}}\" 已存在。是否覆盖?" + "resourceExistsMessage": "资源 \"{{id}}\" 已存在。是否覆盖?", + "import": { + "errors": { + "invalidRoleType": "无效的角色资源:压缩包中未找到 .md 文件", + "invalidToolType": "无效的工具资源:压缩包中未找到 .js 文件", + "mismatchRoleExpectedTool": "类型不匹配:选择了角色资源但压缩包中是工具资源", + "mismatchToolExpectedRole": "类型不匹配:选择了工具资源但压缩包中是角色资源", + "validationFailed": "验证资源类型失败" + } + } }, "errors": { "configLoadFailed": "配置文件存在但加载失败", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index d5df2256..0ac12d7d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,7 +1,7 @@ // Import polyfills first, before any other modules import '~/main/polyfills' -import { BrowserWindow, app, dialog, ipcMain } from 'electron' +import { app, BrowserWindow, dialog, ipcMain } from 'electron' import { TrayPresenter } from '~/main/tray/TrayPresenter' import { ResourceManager } from '~/main/ResourceManager' import { PromptXServerAdapter } from '~/main/infrastructure/adapters/PromptXServerAdapter' @@ -19,7 +19,6 @@ import * as fs from 'node:fs' import { mainI18n, t } from '~/main/i18n' import { ServerConfig } from '~/main/domain/entities/ServerConfig' - class PromptXDesktopApp { private trayPresenter: TrayPresenter | null = null private resourceManager: ResourceManager | null = null @@ -102,6 +101,15 @@ class PromptXDesktopApp { logger.error('Failed to auto-start server:', err) } + // Register global callback for second-instance to open main window + // This is used by bootstrap.ts when user clicks shortcut while app is running + ;(global as any).__promptxOpenMainWindow = () => { + this.trayPresenter?.openMainWindow() + } + + // Auto open main window on startup for better UX + logger.info('Opening main window...') + this.trayPresenter?.openMainWindow() // Auto check and download updates on startup (non-blocking) logger.info('Scheduling automatic update check and download...') @@ -273,17 +281,23 @@ class PromptXDesktopApp { .filter(file => file.startsWith('promptx-') && file.endsWith('.log')) .map(file => { const filePath = path.join(logsDir, file) - const stats = fs.statSync(filePath) - const isError = file.includes('error') - - return { - name: file, - path: filePath, - size: stats.size, - modified: stats.mtime, - type: isError ? 'error' : 'normal' + try { + const stats = fs.statSync(filePath) + const isError = file.includes('error') + + return { + name: file, + path: filePath, + size: stats.size, + modified: stats.mtime, + type: isError ? 'error' : 'normal' + } + } catch { + // 文件可能在读取期间被删除或锁定,跳过 + return null } }) + .filter((log): log is NonNullable => log !== null) .sort((a, b) => b.modified.getTime() - a.modified.getTime()) return { success: true, logs } @@ -337,16 +351,24 @@ class PromptXDesktopApp { const files = fs.readdirSync(logsDir) let deleted = 0 + let skipped = 0 for (const file of files) { if (file.startsWith('promptx-') && file.endsWith('.log')) { const filePath = path.join(logsDir, file) - fs.unlinkSync(filePath) - deleted++ + try { + fs.unlinkSync(filePath) + deleted++ + } catch { + // 文件可能被锁定(如当前正在写入的日志),跳过 + skipped++ + } } } - logger.info(`Cleared ${deleted} log files`) + if (deleted > 0 || skipped === 0) { + logger.info(`Cleared ${deleted} log files${skipped > 0 ? `, ${skipped} skipped (in use)` : ''}`) + } return { success: true, deleted } } catch (error) { logger.error('Failed to clear logs:', String(error)) @@ -416,6 +438,9 @@ class PromptXDesktopApp { } private setupAppEvents(): void { + // NOTE: second-instance handler is set up in bootstrap.ts + // to ensure it's registered before app initialization completes + // Prevent app from quitting when all windows are closed app.on('window-all-closed', () => { // Keep app running in system tray on all platforms @@ -525,6 +550,7 @@ process.stderr.on('error', (error: any) => { }) // Application entry point +// Single instance lock is already checked in bootstrap.ts const application = new PromptXDesktopApp() application.initialize().catch((error) => { diff --git a/apps/desktop/src/main/tray/TrayPresenter.ts b/apps/desktop/src/main/tray/TrayPresenter.ts index a51f5a8b..29ad49da 100644 --- a/apps/desktop/src/main/tray/TrayPresenter.ts +++ b/apps/desktop/src/main/tray/TrayPresenter.ts @@ -237,7 +237,7 @@ export class TrayPresenter { menuItems.push({ id: 'main-window', label: t('tray.menu.openMainWindow'), - click: () => this.handleOpenMainWindow() + click: () => this.openMainWindow() }) menuItems.push({ type: 'separator' }) @@ -377,7 +377,7 @@ export class TrayPresenter { } } - private handleOpenMainWindow(): void { + public openMainWindow(): void { if (this.mainWindow && !this.mainWindow.isDestroyed()) { this.mainWindow.focus() return diff --git a/apps/desktop/src/main/windows/ResourceListWindow.ts b/apps/desktop/src/main/windows/ResourceListWindow.ts index 4902f06c..ee4d9564 100644 --- a/apps/desktop/src/main/windows/ResourceListWindow.ts +++ b/apps/desktop/src/main/windows/ResourceListWindow.ts @@ -15,6 +15,71 @@ export class ResourceListWindow { this.setupIpcHandlers() } + /** + * 验证资源类型是否与压缩包内容匹配 + * @param resourceDir 解压后的资源目录 + * @param expectedType 用户选择的资源类型 ('role' | 'tool') + * @returns { valid: boolean, message?: string } + */ + private async validateResourceType( + resourceDir: string, + expectedType: 'role' | 'tool' + ): Promise<{ valid: boolean; message?: string }> { + const fs = require('fs-extra') + + try { + const files = await fs.readdir(resourceDir) + + // 检查是否包含角色文件 (.md) + const hasMdFile = files.some((f: string) => f.endsWith('.md')) + // 检查是否包含工具文件 (.js) + const hasJsFile = files.some((f: string) => f.endsWith('.js')) + + // TODO: 用户自定义的识别逻辑放在这里 + // 可以根据实际需求扩展验证规则,例如: + // - 检查文件内容格式 + // - 检查特定的元数据字段 + // - 检查文件命名规范等 + + if (expectedType === 'role') { + if (!hasMdFile) { + return { + valid: false, + message: t('resources.import.errors.invalidRoleType') + } + } + // 如果同时有 .js 文件但用户选择了 role,可能是选错了类型 + if (hasJsFile && !hasMdFile) { + return { + valid: false, + message: t('resources.import.errors.mismatchRoleExpectedTool') + } + } + } else if (expectedType === 'tool') { + if (!hasJsFile) { + return { + valid: false, + message: t('resources.import.errors.invalidToolType') + } + } + // 如果同时有 .md 文件但用户选择了 tool,可能是选错了类型 + if (hasMdFile && !hasJsFile) { + return { + valid: false, + message: t('resources.import.errors.mismatchToolExpectedRole') + } + } + } + + return { valid: true } + } catch (error: any) { + return { + valid: false, + message: t('resources.import.errors.validationFailed') + } + } + } + private setupIpcHandlers(): void { // 防止重复注册 if (ResourceListWindow.handlersRegistered) { @@ -472,6 +537,15 @@ export class ResourceListWindow { return { success: false, message: t('resources.invalidResourceStructure') } } + // ==================== 资源类型验证 ==================== + // 验证压缩包内的文件类型是否与用户选择的资源类型匹配 + const validationResult = await this.validateResourceType(resourceDir, type) + if (!validationResult.valid) { + await fs.remove(tempDir) + return { success: false, message: validationResult.message } + } + // ====================================================== + // 使用自定义ID或原ID const finalId = customId || resourceId diff --git a/apps/desktop/src/view/pages/resources-window/components/ResourceImporter.tsx b/apps/desktop/src/view/pages/resources-window/components/ResourceImporter.tsx index 24d72e20..4f149a37 100644 --- a/apps/desktop/src/view/pages/resources-window/components/ResourceImporter.tsx +++ b/apps/desktop/src/view/pages/resources-window/components/ResourceImporter.tsx @@ -78,11 +78,11 @@ export function ResourceImporter({ isOpen, onClose, onImportSuccess }: ResourceI successCount++ } else { failCount++ - console.error(`Failed to import ${filePath}:`, result?.message) + toast.error(t(String(result?.message || 'resources.import.messages.importFailed'))) } } catch (err) { failCount++ - console.error(`Error importing ${filePath}:`, err) + toast.error(t(String(err || 'resources.import.messages.importFailed'))) } } @@ -95,12 +95,10 @@ export function ResourceImporter({ isOpen, onClose, onImportSuccess }: ResourceI resetForm() onClose() onImportSuccess?.() - } else { - toast.error(t('resources.import.messages.importFailed')) - } + } } catch (error) { console.error("Import process failed:", error) - toast.error(t('resources.import.messages.importFailed')) + toast.error(t(String(error))) } finally { setIsImporting(false) } diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index a638c2f6..da91d093 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -98,51 +98,56 @@ export function createLogger(config: LoggerConfig = {}): pino.Logger { const logDir = fileConfig.dirname || path.join(os.homedir(), '.promptx', 'logs') const today = new Date().toISOString().split('T')[0] const logPath = path.join(logDir, `promptx-${today}.log`) - - // Use pino.destination with sync mode for Electron - const dest = pino.destination({ - dest: logPath, - sync: true // Use sync to avoid worker thread issues in Electron - }) - - return pino({ - level: finalConfig.level || 'info', - base: { pid: process.pid }, - mixin: () => getCallerInfo(), - // Simple formatting without pino-pretty to avoid worker threads - formatters: { - level: (label) => { - return { level: label } - }, - log: (obj) => { - const { package: pkg, file, line, ...rest } = obj - return { - ...rest, - location: pkg && file ? `${pkg} [${file}:${line}]` : undefined + + try { + // Use pino.destination with sync mode for Electron + const dest = pino.destination({ + dest: logPath, + sync: true // Use sync to avoid worker thread issues in Electron + }) + + return pino({ + level: finalConfig.level || 'info', + base: { pid: process.pid }, + mixin: () => getCallerInfo(), + // Simple formatting without pino-pretty to avoid worker threads + formatters: { + level: (label) => { + return { level: label } + }, + log: (obj) => { + const { package: pkg, file, line, ...rest } = obj + return { + ...rest, + location: pkg && file ? `${pkg} [${file}:${line}]` : undefined + } } } - } - }, dest) - } else { - // Console only mode for Electron without pino-pretty - return pino({ - level: finalConfig.level || 'info', - base: { pid: process.pid }, - mixin: () => getCallerInfo(), - formatters: { - level: (label) => { - return { level: label } - }, - log: (obj) => { - const { package: pkg, file, line, ...rest } = obj - return { - ...rest, - location: pkg && file ? `${pkg} [${file}:${line}]` : undefined - } + }, dest) + } catch (err) { + // File may be locked by another instance, fall back to console only + console.warn(`[logger] Cannot open log file (may be locked by another instance): ${logPath}`) + } + } + + // Console only mode for Electron without pino-pretty (or file open failed) + return pino({ + level: finalConfig.level || 'info', + base: { pid: process.pid }, + mixin: () => getCallerInfo(), + formatters: { + level: (label) => { + return { level: label } + }, + log: (obj) => { + const { package: pkg, file, line, ...rest } = obj + return { + ...rest, + location: pkg && file ? `${pkg} [${file}:${line}]` : undefined } } - }) - } + } + }) } else { // Use transports for non-Electron environments (better for servers) const targets: any[] = []