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
13 changes: 13 additions & 0 deletions apps/electron/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ compression: normal
asar: true
asarUnpack:
- "node_modules/@anthropic-ai/**"
- "node_modules/node-pty/**"

# 跳过 npm install(代码已被 esbuild/Vite 打包,npm 不识别 workspace:* 协议)
npmRebuild: false

# node-pty 的 spawn-helper 必须在签名前具备可执行权限。
afterPack: scripts/fix-node-pty-permissions.cjs

# 通用文件配置
files:
- dist/**/*
Expand All @@ -44,6 +48,15 @@ files:
- node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64/**/*
# PDF 内联预览使用本地 PDF.js 资源,避免运行时依赖 CDN。
- node_modules/pdfjs-dist/**/*
# 右侧面板终端使用 node-pty native module,必须与 dist/main.cjs 同级保留。
- from: ../../node_modules/node-pty
to: node_modules/node-pty
filter:
- "**/*"
- from: ../../node_modules/node-addon-api
to: node_modules/node-addon-api
filter:
- "**/*"
# 排除 workspace 包的 symlink(代码已被 esbuild/Vite 打包到 dist/ 中)
- "!node_modules/@proma/**"

Expand Down
8 changes: 6 additions & 2 deletions apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
"dev:vite": "vite dev",
"dev:kill": "bun run scripts/dev-kill.ts",
"dev:electron": "bun run dev:kill && bun run build:main && bun run build:preload && bun run build:resources && bun run scripts/sleep.ts 2 && concurrently -k -n main,preload,app -c yellow,magenta,cyan \"bun run watch:main\" \"bun run watch:preload\" \"bunx electronmon .\"",
"build:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk",
"fix:node-pty": "node scripts/fix-node-pty-permissions.cjs",
"build:main": "bun run fix:node-pty && esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --external:node-pty",
"build:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload.cjs --external:electron",
"watch:main": "esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --watch=forever",
"watch:main": "bun run fix:node-pty && esbuild src/main/index.ts --bundle --platform=node --format=cjs --outfile=dist/main.cjs --external:electron --external:@anthropic-ai/claude-agent-sdk --external:node-pty --watch=forever",
"watch:preload": "esbuild src/preload/index.ts --bundle --platform=node --format=cjs --outfile=dist/preload.cjs --external:electron --watch=forever",
"build:renderer": "vite build",
"build:resources": "cp -r resources dist/ 2>/dev/null || true",
Expand All @@ -42,10 +43,13 @@
"@modelcontextprotocol/sdk": "^1.29.0",
"@pierre/diffs": "^1.1.20",
"@xmldom/xmldom": "^0.8.11",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"adm-zip": "^0.5.17",
"dompurify": "^3.4.5",
"iconv-lite": "^0.6.3",
"mammoth": "^1.12.0",
"node-pty": "1.1.0",
"pdfjs-dist": "^4.10.38"
},
"optionalDependencies": {
Expand Down
137 changes: 137 additions & 0 deletions apps/electron/scripts/fix-node-pty-permissions.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const { chmodSync, existsSync, readdirSync, statSync } = require('node:fs')
const { dirname, join, resolve } = require('node:path')

const SCRIPT_DIR = __dirname
const APP_DIR = resolve(SCRIPT_DIR, '..')
const REPO_ROOT = resolve(APP_DIR, '../..')

function resolveNodePtyRoot() {
const packagePath = require.resolve('node-pty/package.json', {
paths: [APP_DIR, REPO_ROOT],
})
return dirname(packagePath)
}

function isExecutable(filePath) {
if (process.platform === 'win32') return true
return (statSync(filePath).mode & 0o111) !== 0
}

function chmodExecutable(filePath) {
if (process.platform === 'win32') return
if (!existsSync(filePath)) return
chmodSync(filePath, 0o755)
console.log(`[node-pty] 已设置可执行权限: ${filePath}`)
}

function currentPlatformArch() {
return `${process.platform}-${process.arch}`
}

function validateNodePtyRoot(nodePtyRoot, platformArch = currentPlatformArch()) {
const prebuildRoot = join(nodePtyRoot, 'prebuilds', platformArch)
const buildRoot = join(nodePtyRoot, 'build', 'Release')
const nativeCandidates = [
join(prebuildRoot, 'pty.node'),
join(buildRoot, 'pty.node'),
]
const nativeModule = nativeCandidates.find((candidate) => existsSync(candidate))

if (!nativeModule) {
throw new Error(`[node-pty] 缺少当前平台 native module: ${nativeCandidates.join(', ')}`)
}

if (!platformArch.startsWith('win32-')) {
const helperCandidates = [
join(prebuildRoot, 'spawn-helper'),
join(buildRoot, 'spawn-helper'),
]
const helper = helperCandidates.find((candidate) => existsSync(candidate))
if (!helper) {
throw new Error(`[node-pty] 缺少 spawn-helper: ${helperCandidates.join(', ')}`)
}
chmodExecutable(helper)
if (!isExecutable(helper)) {
throw new Error(`[node-pty] spawn-helper 仍不可执行: ${helper}`)
}
}

console.log(`[node-pty] native 产物校验通过: ${nativeModule}`)
}

function findAppResourceDirs(appOutDir) {
const resourceDirs = []
const resourcesDir = join(appOutDir, 'resources')
if (existsSync(resourcesDir)) resourceDirs.push(resourcesDir)

if (existsSync(appOutDir)) {
for (const entry of readdirSync(appOutDir)) {
if (!entry.endsWith('.app')) continue
const macResourcesDir = join(appOutDir, entry, 'Contents', 'Resources')
if (existsSync(macResourcesDir)) resourceDirs.push(macResourcesDir)
}
}

return resourceDirs
}

function collectPackagedNodePtyRoots(appOutDir) {
const roots = new Set()
for (const resourcesDir of findAppResourceDirs(appOutDir)) {
for (const relativeRoot of [
'app.asar.unpacked/node_modules/node-pty',
'app/node_modules/node-pty',
]) {
const nodePtyRoot = join(resourcesDir, relativeRoot)
if (existsSync(nodePtyRoot)) roots.add(nodePtyRoot)
}
}
return Array.from(roots)
}

function platformArchFromContext(context) {
const platformName = context.electronPlatformName || context.packager?.platform?.nodeName || process.platform
const archName = normalizeArch(context.arch || context.packager?.arch || process.arch)
return `${platformName}-${archName}`
}

function normalizeArch(arch) {
if (typeof arch === 'string') return arch
if (typeof arch !== 'number') return process.arch
const archNames = {
0: 'ia32',
1: 'x64',
2: 'armv7l',
3: 'arm64',
4: 'universal',
}
return archNames[arch] || process.arch
}

async function afterPack(context) {
const roots = collectPackagedNodePtyRoots(context.appOutDir)
if (roots.length === 0) {
throw new Error(`[node-pty] 打包产物中未找到 node-pty: ${context.appOutDir}`)
}

const platformArch = platformArchFromContext(context)
for (const nodePtyRoot of roots) {
validateNodePtyRoot(nodePtyRoot, platformArch)
}
}

function runLocal() {
const nodePtyRoot = resolveNodePtyRoot()
validateNodePtyRoot(nodePtyRoot)
}

module.exports = afterPack

if (require.main === module) {
try {
runLocal()
} catch (error) {
console.error(error instanceof Error ? error.message : error)
process.exit(1)
}
}
87 changes: 86 additions & 1 deletion apps/electron/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { join, resolve, sep, dirname } from 'node:path'
import { existsSync, realpathSync, rmSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, INSTALLER_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, CHAT_TOOL_IPC_CHANNELS, FEISHU_IPC_CHANNELS, DINGTALK_IPC_CHANNELS, WECHAT_IPC_CHANNELS, AUTOMATION_IPC_CHANNELS, isPromaPermissionMode, normalizePathForCompare } from '@proma/shared'
import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, INSTALLER_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, CHAT_TOOL_IPC_CHANNELS, FEISHU_IPC_CHANNELS, DINGTALK_IPC_CHANNELS, WECHAT_IPC_CHANNELS, AUTOMATION_IPC_CHANNELS, TERMINAL_IPC_CHANNELS, isPromaPermissionMode, normalizePathForCompare } from '@proma/shared'
import { USER_PROFILE_IPC_CHANNELS, SETTINGS_IPC_CHANNELS, SCRATCH_PAD_IPC_CHANNELS, QUICK_TASK_IPC_CHANNELS, VOICE_DICTATION_IPC_CHANNELS, APP_ICON_IPC_CHANNELS, DOCK_BADGE_IPC_CHANNELS, STORAGE_IPC_CHANNELS } from '../types'
import type {
QuickTaskSubmitInput,
Expand Down Expand Up @@ -110,6 +110,16 @@ import type {
Automation,
CreateAutomationInput,
UpdateAutomationInput,
TerminalSession,
TerminalStartInput,
TerminalListInput,
TerminalListResult,
TerminalWriteInput,
TerminalStopInput,
TerminalResizeInput,
TerminalSetActiveSessionInput,
TerminalBusyInput,
TerminalBusyResult,
} from '@proma/shared'
import type { UserProfile, AppSettings } from '../types'
import { getRuntimeStatus, getGitRepoStatus, reinitializeRuntime } from './lib/runtime-init'
Expand Down Expand Up @@ -261,6 +271,7 @@ import { getDingTalkConfig, saveDingTalkConfig, getDecryptedClientSecret, getDin
import { dingtalkBridgeManager } from './lib/dingtalk-bridge-manager'
import { getWeChatConfig } from './lib/wechat-config'
import { wechatBridge } from './lib/wechat-bridge'
import { getTerminalBusyState, listTerminalsForSession, resizeTerminal, setActiveTerminalSession, startTerminal, stopAllTerminals, stopTerminal, writeTerminal } from './lib/terminal-service'

/** 文件浏览器中需要隐藏的系统文件 */
const HIDDEN_FS_ENTRIES = new Set(['.DS_Store', 'Thumbs.db'])
Expand Down Expand Up @@ -798,6 +809,7 @@ export function resolveAppIconPath(variantId: string): string | null {

export function registerIpcHandlers(): void {
console.log('[IPC] 正在注册 IPC 处理器...')
app.once('before-quit', () => stopAllTerminals())

// ===== 运行时相关 =====

Expand Down Expand Up @@ -830,6 +842,79 @@ export function registerIpcHandlers(): void {
}
)

// ===== 终端相关 =====

ipcMain.handle(
TERMINAL_IPC_CHANNELS.START,
async (event, input: TerminalStartInput): Promise<TerminalSession> => {
if (!input || typeof input !== 'object') throw new Error('input 必须是对象')
if (!input.sessionId || typeof input.sessionId !== 'string') throw new Error('sessionId 必填')
if (!input.cwd || typeof input.cwd !== 'string') throw new Error('cwd 必填')
const cwd = resolve(input.cwd)
if (!ensurePathAllowed(cwd, { sessionId: input.sessionId })) {
throw new Error('终端工作目录不在当前会话允许范围内')
}
setActiveTerminalSession(event.sender.id, input.sessionId)
return startTerminal({ sessionId: input.sessionId, cwd, cols: input.cols, rows: input.rows }, event.sender)
}
)

ipcMain.handle(
TERMINAL_IPC_CHANNELS.LIST,
async (event, input: TerminalListInput): Promise<TerminalListResult> => {
if (!input || typeof input !== 'object') throw new Error('input 必须是对象')
if (!input.sessionId || typeof input.sessionId !== 'string') throw new Error('sessionId 必填')
return listTerminalsForSession(input.sessionId, event.sender.id)
}
)

ipcMain.handle(
TERMINAL_IPC_CHANNELS.WRITE,
async (event, input: TerminalWriteInput): Promise<void> => {
if (!input || typeof input !== 'object') throw new Error('input 必须是对象')
if (!input.terminalId || typeof input.terminalId !== 'string') throw new Error('terminalId 必填')
if (typeof input.data !== 'string') throw new Error('data 必须是字符串')
writeTerminal(input.terminalId, input.data, event.sender.id)
}
)

ipcMain.handle(
TERMINAL_IPC_CHANNELS.RESIZE,
async (event, input: TerminalResizeInput): Promise<void> => {
if (!input || typeof input !== 'object') throw new Error('input 必须是对象')
if (!input.terminalId || typeof input.terminalId !== 'string') throw new Error('terminalId 必填')
if (!Number.isFinite(input.cols) || !Number.isFinite(input.rows)) throw new Error('cols/rows 必须是数字')
resizeTerminal(input, event.sender.id)
}
)

ipcMain.handle(
TERMINAL_IPC_CHANNELS.STOP,
async (event, input: TerminalStopInput): Promise<void> => {
if (!input || typeof input !== 'object') throw new Error('input 必须是对象')
if (!input.terminalId || typeof input.terminalId !== 'string') throw new Error('terminalId 必填')
stopTerminal(input.terminalId, event.sender.id)
}
)

ipcMain.handle(
TERMINAL_IPC_CHANNELS.SET_ACTIVE_SESSION,
async (event, input: TerminalSetActiveSessionInput): Promise<void> => {
if (!input || typeof input !== 'object') throw new Error('input 必须是对象')
if (input.sessionId !== null && typeof input.sessionId !== 'string') throw new Error('sessionId 必须是字符串或 null')
setActiveTerminalSession(event.sender.id, input.sessionId)
}
)

ipcMain.handle(
TERMINAL_IPC_CHANNELS.IS_BUSY,
async (event, input: TerminalBusyInput): Promise<TerminalBusyResult> => {
if (!input || typeof input !== 'object') throw new Error('input 必须是对象')
if (!input.terminalId || typeof input.terminalId !== 'string') throw new Error('terminalId 必填')
return getTerminalBusyState(input.terminalId, event.sender.id)
}
)

// 获取未暂存的变更文件列表
ipcMain.handle(
IPC_CHANNELS.GET_UNSTAGED_CHANGES,
Expand Down
4 changes: 4 additions & 0 deletions apps/electron/src/main/lib/agent-session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
getSdkConfigDir,
} from './config-paths'
import { getAgentWorkspace } from './agent-workspace-manager'
import { stopTerminalsForSession } from './terminal-service'

// 在模块加载时一次性设置 SDK 配置目录,避免在 forkSession 等异步调用中临时修改/恢复
// process.env 导致的并发安全问题(异步操作的 await 间隙其他代码可能读到错误值)
Expand Down Expand Up @@ -393,6 +394,9 @@ export function deleteAgentSession(id: string): void {
// 清理 Nano Banana 生图历史
clearNanoBananaAgentHistory(id)

// 停止该会话的全部终端,避免后台 PTY 进程泄漏(覆盖单会话删除与删工作区批量删除两条路径)
stopTerminalsForSession(id)

// 清理 SDK 关联数据(file-history 和 projects 下的 session JSONL)
const sdkSessionIds = [removed.sdkSessionId, removed.forkSourceSdkSessionId].filter(Boolean) as string[]
if (sdkSessionIds.length > 0) {
Expand Down
Loading