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
9 changes: 9 additions & 0 deletions .changeset/refresh-batch-import.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions .changeset/silent-sounds-fix.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions .changeset/single-instance-ux.md
Original file line number Diff line number Diff line change
@@ -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
43 changes: 41 additions & 2 deletions apps/desktop/src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,44 @@ if (typeof globalThis.File === 'undefined') {
}
}

// Now import the actual app
import('./index.js')
// ==================== 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')
}
// ==============================================================
14 changes: 13 additions & 1 deletion apps/desktop/src/main/i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"app": {
"alreadyRunning": "PromptX is already running in the system tray"
},
"tray": {
"tooltip": "PromptX",
"status": {
Expand Down Expand Up @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion apps/desktop/src/main/i18n/zh-CN.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"app": {
"alreadyRunning": "PromptX 已在系统托盘中运行"
},
"tray": {
"tooltip": "PromptX",
"status": {
Expand Down Expand Up @@ -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": "配置文件存在但加载失败",
Expand Down
54 changes: 40 additions & 14 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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...')
Expand Down Expand Up @@ -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<typeof log> => log !== null)
.sort((a, b) => b.modified.getTime() - a.modified.getTime())

return { success: true, logs }
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/tray/TrayPresenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -377,7 +377,7 @@ export class TrayPresenter {
}
}

private handleOpenMainWindow(): void {
public openMainWindow(): void {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.focus()
return
Expand Down
74 changes: 74 additions & 0 deletions apps/desktop/src/main/windows/ResourceListWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')))
}
}

Expand All @@ -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)
}
Expand Down
Loading