const {
app,
BrowserWindow,
Menu,
ipcMain,
net,
safeStorage,
shell,
nativeImage,
dialog,
powerMonitor
} = require('electron')
const path = require('path')
const fs = require('fs')
const os = require('os')
const crypto = require('node:crypto')
const { spawn, spawnSync } = require('child_process')
const { EventEmitter } = require('events')
const appMetadata = require('./package.json')
app.commandLine.appendSwitch('js-flags', '--max-old-space-size=4096')
const APP_ID = String(appMetadata.build?.appId || 'com.cide.app')
const APP_ICON_PATH = path.join(
__dirname,
'assets',
'branding',
'wangpeng-cangjie-icon.ico'
)
if (process.platform === 'win32') {
app.setAppUserModelId(APP_ID)
app.disableHardwareAcceleration()
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
}
function quoteWindowsCommandArgument(value) {
const text = String(value)
return `"${text
.replace(/(\\*)"/g, '$1$1\\"')
.replace(/(\\+)$/, '$1$1')}"`
}
function getWindowsRelaunchCommand() {
const command = [process.execPath]
if (!app.isPackaged) command.push(app.getAppPath())
return command.map(quoteWindowsCommandArgument).join(' ')
}
function applyWindowsTaskbarBranding(win) {
if (
process.platform !== 'win32' ||
typeof win?.setAppDetails !== 'function'
) {
return
}
win.setAppDetails({
appId: APP_ID,
appIconPath: app.isPackaged ? process.execPath : APP_ICON_PATH,
appIconIndex: 0,
relaunchCommand: getWindowsRelaunchCommand(),
relaunchDisplayName: 'CIDE'
})
}
if (process.env.CIDE_E2E_USER_DATA) {
app.setPath(
'userData',
path.resolve(process.env.CIDE_E2E_USER_DATA)
)
} else if (app.isPackaged) {
// 免安装版:exe 同目录存在 CIDE.portable 标记时,
// 所有用户数据写入程序目录下的 data(可随 U 盘迁移、删目录即卸载)
const {
resolvePortableUserData
} = require('./js/core/portable-core')
const portableUserData = resolvePortableUserData(
process.execPath,
fs.existsSync
)
if (portableUserData) {
fs.mkdirSync(portableUserData, { recursive: true })
app.setPath('userData', portableUserData)
}
}
const {
CangjieLspManager,
LSP_FILE_CHANGE_TYPE
} = require('./js/cangjie-lsp-client')
const { CangjieDebugManager } = require('./js/cangjie-debug-client')
const {
getGitConflictLabel,
getSafeGitRemoteLabel,
parseGitStatus,
splitGitRemoteBranch
} = require('./js/core/git-core')
const {
OUTPUT_ROUTE,
classifyBuildOutputLine,
isCompileFailureEvidence,
isCompilerDecorationLine,
normalizePtyOutput,
parseRunStatusLine,
shouldEmitRuntimeOutputLine
} = require('./js/core/service-output-core')
const {
injectCangjieSdkPath,
resolveCangjieExecutable,
resolveServiceWorkDir,
shouldUsePtyForServiceRun
} = require('./js/core/service-run-core')
const {
translateCjCompilerLine,
translateExpectedFoundLine,
translateCjRuntimeLine,
formatZhHint
} = require('./js/core/cj-error-zh')
const {
isCangjieSdkRoot,
resolveCangjieSdk,
enumerateSdkVersions
} = require('./js/core/cangjie-sdk-core')
const {
findGitExecutable,
resolveGitInstallation
} = require('./js/core/git-executable-core')
const { registerGitIpc } = require('./js/main/git-ipc')
const {
createGitConflictService
} = require('./js/main/git-conflict-service')
const {
registerPlatformIpc,
registerProjectRoot,
clearWebContentsRoots,
isPathAllowedForSender
} = require('./js/main/platform-ipc')
const {
createProjectTreeScanner
} = require('./js/main/project-tree-scanner')
const {
createProjectSearchService,
registerProjectSearchIpc
} = require('./js/main/project-search-service')
const {
createCangjieWorkspaceIndexer,
registerCangjieWorkspaceIndexIpc
} = require('./js/main/cangjie-workspace-indexer')
const {
registerCangjieThrowsScannerIpc
} = require('./js/main/cangjie-throws-scanner')
const {
registerCangjieLspIpc
} = require('./js/main/cangjie-lsp-ipc')
const {
registerCangjieCjoIpc
} = require('./js/main/cangjie-cjo-ipc')
const {
registerCangjieDocIpc
} = require('./js/main/cangjie-doc-ipc')
const {
registerStdxDocIpc
} = require('./js/main/stdx-doc-ipc')
const {
registerCourseContentIpc
} = require('./js/main/course-content-ipc')
const {
registerContactQrcodeIpc
} = require('./js/main/contact-qrcode-ipc')
const {
applyLspWorkspaceEdit
} = require('./js/main/lsp-workspace-edit-service')
const { createSettingsStore } = require('./js/main/settings-store')
const {
createSecretCrypto,
createPassthroughCrypto
} = require('./js/main/secret-crypto')
const {
createWorkspaceSessionStore,
registerWorkspaceSessionIpc
} = require('./js/main/workspace-session-store')
const {
attachWindowSecurity,
createSecureWebPreferences
} = require('./js/main/window-security')
const { registerAiProxyIpc } = require('./js/main/ai-proxy-ipc')
const {
createProcessLifecycle
} = require('./js/main/process-lifecycle')
const {
captureProcessErrors,
createAppLogger
} = require('./js/main/app-logger')
const {
createRendererCrashGuard
} = require('./js/main/renderer-crash-guard')
const {
createRecoveryStore,
registerRecoveryIpc
} = require('./js/main/recovery-store')
const {
EditorFileError,
MISSING_VERSION,
readEditorFile,
saveEditorFile
} = require('./js/main/editor-file-store')
const {
createUpdateService,
registerUpdateIpc
} = require('./js/main/update-service')
const {
registerTerminalIpc
} = require('./js/main/terminal-ipc')
const {
createNotesService,
registerNotesIpc
} = require('./js/main/notes-service')
const {
createCangjieSdkManager
} = require('./js/main/cangjie-sdk')
const {
createBuildStateManager,
isPathInside,
IGNORED_BUILD_DIRECTORIES
} = require('./js/main/build-state')
const {
registerTestRunnerIpc
} = require('./js/main/test-runner-ipc')
const {
registerCangjieFormatFolderIpc
} = require('./js/main/cangjie-format-folder-ipc')
const {
registerCjpmDependencyIpc
} = require('./js/main/cjpm-dependency-ipc')
const {
registerTodoScanIpc
} = require('./js/main/todo-scan-ipc')
const {
registerRunConfigIpc
} = require('./js/main/run-config-ipc')
const {
registerProjectPickerIpc
} = require('./js/main/project-picker')
const {
registerCangjieDebugIpc
} = require('./js/main/cangjie-debug')
const {
registerCangjieHeapIpc
} = require('./js/main/cangjie-heap')
const {
registerEditorFileIpc
} = require('./js/main/editor-file-io')
const {
registerTreeOperationsIpc
} = require('./js/main/tree-operations')
const {
createGitOperations
} = require('./js/main/git-operations')
const {
createServiceRun
} = require('./js/main/service-run')
const {
createOpenSSLDownloader
} = require('./js/main/openssl-downloader')
const {
createProjectCreation
} = require('./js/main/project-creation')
const settingsStoreOptions = process.env.CIDE_E2E_USER_DATA
? {
settingsDir: path.join(app.getPath('userData'), '.cang-ide')
}
: {}
// 密钥静态加密:只要 safeStorage 存在就用 createSecretCrypto(它在每次读写时惰性
// 检测可用性,避开“模块加载期 app 未 ready 导致误判不可用”),加密不可用时自动退回
// 明文读写(可用性优先,绝不因加密失败而丢 Key);safeStorage 完全缺失才用直通。
settingsStoreOptions.secretCrypto = safeStorage
? createSecretCrypto(safeStorage)
: createPassthroughCrypto()
const { loadSettings, saveSettings } = createSettingsStore(
settingsStoreOptions
)
// 文件日志(userData/logs):下载器等模块统一写文件,不向 CMD 控制台打印
const logger = createAppLogger({
getDirectory: () => path.join(app.getPath('userData'), 'logs')
})
captureProcessErrors(logger)
// OpenSSL 运行时与下载安装器:内置出厂兜底;用户可在设置里下载官方二进制版本
const opensslRuntime = require('./js/main/openssl-runtime')
const opensslDownloader = createOpenSSLDownloader({
app,
logger,
safeSend,
getWindows: () => windows,
opensslRuntime
})
opensslDownloader.restoreOnStartup().catch(() => { /* OpenSSL 启动恢复失败在使用时会再次尝试 */ })
// STDX 自动下载安装器:从 GitCode releases 拉预编译二进制 zip,
// 完整 stdx 目录(含 .cjo/.dll)安装到用户选择的位置
const { createStdxDownloader } = require('./js/main/stdx-downloader')
const stdxDownloader = createStdxDownloader({ app, logger, safeSend })
const cangjieSdkManager = createCangjieSdkManager({ loadSettings })
const {
getFreshPathAsync: _getFreshPathAsync,
getFreshPath: _getFreshPath,
setEnvPath: _setEnvPath,
resolveSdk: _resolveCangjieSdk,
getCjHome: _getCjHome
} = cangjieSdkManager
const buildStateManager = createBuildStateManager()
const {
getWindowBuildStates,
getCangjieBuildState,
clearCangjieBuildStates,
clearAllBuildStates,
markCangjieBuildInputsChanged,
markBuildStateChanged,
getCangjieBuildMode,
completeCangjieBuild,
normalizeBuildPath,
isIgnoredBuildInput
} = buildStateManager
const processLifecycle = createProcessLifecycle({
onEvent: event => logger.info('process-lifecycle', event)
})
const projectTreeScanner = createProjectTreeScanner()
const projectSearchService = createProjectSearchService()
const cangjieWorkspaceIndexer = createCangjieWorkspaceIndexer()
function getDefaultNotesDirectory() {
try {
return path.join(app.getPath('documents'), 'CIDE Notes')
} catch (error) {
return path.join(app.getPath('userData'), 'CIDE Notes')
}
}
const notesService = createNotesService({
loadSettings,
defaultDirectory: getDefaultNotesDirectory()
})
const recoveryStore = createRecoveryStore({
directory: path.join(app.getPath('userData'), 'recovery')
})
const workspaceSessionStore = createWorkspaceSessionStore({
sessionFile: path.join(
app.getPath('userData'),
'workspace-session.json'
)
})
recoveryStore.cleanup()
registerPlatformIpc(ipcMain, {
userDataRoot: app.getPath('userData'),
sdkRootProvider: () => cangjieSdkManager.getCjHome()
})
// preload 运行在沙箱中,require('electron') 只提供 contextBridge/ipcRenderer,
// 拿不到 app 模块。版本号经同步通道提供——若在 preload 里直接调
// app.getVersion() 会抛 TypeError 并导致整个 contextBridge 桥接挂载失败。
ipcMain.on('cide-app-version-sync', event => {
event.returnValue = app.getVersion()
})
registerAiProxyIpc(ipcMain, { logger, net })
registerRecoveryIpc(ipcMain, recoveryStore, logger)
registerWorkspaceSessionIpc(ipcMain, workspaceSessionStore)
registerProjectSearchIpc(ipcMain, projectSearchService, logger)
registerNotesIpc(ipcMain, notesService, {
showItemInFolder: targetPath => shell.showItemInFolder(targetPath)
})
registerCangjieWorkspaceIndexIpc(
ipcMain,
cangjieWorkspaceIndexer,
logger
)
registerCangjieThrowsScannerIpc(ipcMain, cangjieSdkManager)
const updateService = createUpdateService({
app,
electronNet: net,
shell,
loadSettings,
logger,
releaseChannel: appMetadata.releaseChannel,
defaultUpdateUrl: appMetadata.defaultUpdateUrl,
manifestPublicKey: appMetadata.updatePublicKey,
requireManifestSignature: false,
downloadDirectory: path.join(
app.getPath('userData'),
'updates'
)
})
registerUpdateIpc(ipcMain, updateService, {
dialog,
BrowserWindow,
app
})
// —— 自动检查更新:启动后 10 秒检查,之后每 1 小时检查一次 ——
const UPDATE_CHECK_STARTUP_DELAY_MS = 10 * 1000
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000
let lastNotifiedVersion = ''
async function autoCheckForUpdates() {
try {
const result = await updateService.check()
if (result.success && result.available) {
const newVersion = result.update.version
// 同一版本只通知一次
if (newVersion !== lastNotifiedVersion) {
lastNotifiedVersion = newVersion
const win = getFocusedWindow()
if (win) {
safeSend(win.webContents, 'update-available', {
version: newVersion,
notes: result.update.notes || '',
isPortable: result.isPortable
})
}
}
}
} catch (error) {
logger.info('auto-update-check-skipped', {
error: error.message
})
}
}
setTimeout(() => {
void autoCheckForUpdates()
}, UPDATE_CHECK_STARTUP_DELAY_MS)
setInterval(() => {
void autoCheckForUpdates()
}, UPDATE_CHECK_INTERVAL_MS)
logger.info('application-starting', {
version: app.getVersion(),
packaged: app.isPackaged
})
ipcMain.on('app-log', (event, payload = {}) => {
const allowedLevels = new Set([
'debug',
'info',
'warn',
'error'
])
const level = allowedLevels.has(payload.level)
? payload.level
: 'info'
logger[level](
`renderer:${String(payload.message || '').slice(0, 1000)}`,
{
webContentsId: event.sender.id,
context: payload.context
}
)
})
ipcMain.handle('app-open-logs', async () => {
const error = await shell.openPath(logger.getDirectory())
return {
success: !error,
error: error || '',
path: logger.getDirectory()
}
})
// 在系统默认浏览器打开外部链接。渲染进程的 <a target=_blank>/window.open
// 已被 setWindowOpenHandler 无条件 deny,外链只能经此白名单通道交给 shell。
ipcMain.handle('open-external-url', async (_event, payload) => {
const raw = String(payload?.url || '')
let parsed
try {
parsed = new URL(raw)
} catch (_error) {
return { success: false, error: 'Invalid URL.' }
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { success: false, error: 'Only http/https URLs can be opened.' }
}
try {
await shell.openExternal(parsed.toString())
return { success: true, error: '' }
} catch (error) {
logger?.warn?.('open-external-url-failed', {
webContentsId: _event?.sender?.id,
message: error?.message || String(error)
})
return { success: false, error: error?.message || String(error) }
}
})
// ============== Git 操作(模块化) ==============
const gitOps = createGitOperations({
path,
fs,
spawn,
loadSettings,
resolveGitInstallation,
parseGitStatus,
getGitConflictLabel,
getSafeGitRemoteLabel,
splitGitRemoteBranch,
isPathInside
})
const {
getGitRepositoryRoot,
getGitState,
runGit,
validateGitRelativePath,
createGitPullConflictResult,
ensureExpectedGitBranch,
getCurrentGitBranch,
getGitCommitDetail,
getGitCompareFile,
getGitCompareFiles,
getGitCompareRefs,
getGitDiff,
getGitIncomingPaths,
getGitLog,
getGitLogAuthors,
getGitOperationContext,
getGitPushContext,
validateGitRemoteTarget
} = gitOps
// 用 Map 跟踪所有窗口,key 为 webContents.id
const windows = new Map()
const projectWatchers = new Map()
const startupControllers = new Map()
const approvedWindowCloses = new WeakSet()
const pendingWindowCloses = new WeakMap()
const windowStates = new Map() // 保存 Welcome 之前的窗口状态 { isMaximized, bounds }
let nextWindowCloseRequestId = 0
const ignoredBuildDirectories = IGNORED_BUILD_DIRECTORIES
// 渲染进程崩溃自动重载(带时间窗口内的次数上限,防止崩溃循环)
const rendererCrashGuard = createRendererCrashGuard()
// ============== 服务运行(模块化) ==============
const serviceRun = createServiceRun({
path,
fs,
spawn,
spawnSync,
EventEmitter,
classifyBuildOutputLine,
isCompileFailureEvidence,
isCompilerDecorationLine,
normalizePtyOutput,
parseRunStatusLine,
shouldEmitRuntimeOutputLine,
OUTPUT_ROUTE,
translateCjCompilerLine,
translateExpectedFoundLine,
translateCjRuntimeLine,
formatZhHint,
resolveCangjieExecutable,
resolveServiceWorkDir,
shouldUsePtyForServiceRun,
safeSend,
logger,
processLifecycle,
getWindows: () => windows,
getProjectWatchers: () => projectWatchers,
getFreshPathAsync: _getFreshPathAsync,
setEnvPath: _setEnvPath,
getCjHome: _getCjHome,
getCangjieBuildState,
markCangjieBuildInputsChanged,
getCangjieBuildMode,
completeCangjieBuild,
prependOpenSSLToPath: (env) => require('./js/main/openssl-runtime').prependOpenSSLToPath(env)
})
// ============== 项目创建(模块化) ==============
const projectCreation = createProjectCreation({
path,
fs,
spawn,
app,
loadSettings,
saveSettings,
enumerateSdkVersions,
resolveCangjieExecutable,
resolveGitInstallation,
resolveCangjieSdk,
safeSend,
setEnvPath: _setEnvPath,
getFreshPath: _getFreshPath,
getCjHome: _getCjHome
})
function handleLspWorkspaceEditApplied(
webContentsId,
projectRoot,
result,
sendResult = false
) {
const changedPaths = new Set()
let hasResourceOperations = false
for (const operation of Array.isArray(result?.fileOperations)
? result.fileOperations
: []) {
if (operation.filePath) changedPaths.add(operation.filePath)
if (operation.oldPath) changedPaths.add(operation.oldPath)
if (operation.newPath) changedPaths.add(operation.newPath)
if (['create', 'rename', 'delete'].includes(operation.kind)) {
hasResourceOperations = true
}
}
for (const document of Array.isArray(result?.documents)
? result.documents
: []) {
changedPaths.add(document.filePath)
}
const win = windows.get(webContentsId)
if (!win) return
if (sendResult) {
safeSend(
win.webContents,
'lsp-workspace-edit-applied',
result
)
}
const paths = Array.from(changedPaths)
if (paths.length > 0) {
markCangjieBuildInputsChanged(webContentsId, paths)
safeSend(win.webContents, 'project-files-changed', {
projectPath: projectRoot,
changedPaths: paths
})
}
if (hasResourceOperations) {
safeSend(win.webContents, 'project-tree-changed', {
projectPath: projectRoot
})
}
}
const cangjieLspManager = new CangjieLspManager({
processLifecycle,
logger,
workspaceEditHandler: payload => {
const result = applyLspWorkspaceEdit(payload)
handleLspWorkspaceEditApplied(
payload.webContentsId,
payload.projectRoot,
result,
true
)
return result
}
})
// 构建完成后需要让 .cjo 清单重扫 target(发现新编译出的三方依赖),
// 而 .cjo IPC 在 LSP IPC 之后注册,这里用前向引用承接其 invalidate。
let cangjieCjoIpcApi = null
const cangjieLspIpcApi = registerCangjieLspIpc(ipcMain, {
manager: cangjieLspManager,
getSdkRoot: () => _getCjHome(),
getStdxRoot: () => String(loadSettings().stdxLibPath || '').trim(),
getAutoStdxDir: () => stdxDownloader.getLatestInstalledDir(),
findProjectRoot: projectCreation.findCangjieProjectDir,
onWorkspaceEditApplied: (
webContentsId,
projectRoot,
result
) => {
handleLspWorkspaceEditApplied(
webContentsId,
projectRoot,
result
)
},
onBuildComplete: () => {
cangjieCjoIpcApi?.invalidate()
// import 路径补全清单同样缓存了 target 依赖包,需要一并重扫
cangjieLspIpcApi?.invalidateSdkPackages()
},
logger
})
cangjieCjoIpcApi = registerCangjieCjoIpc(ipcMain, {
getSdkRoot: () => _getCjHome(),
getStdxRoot: () => String(loadSettings().stdxLibPath || '').trim(),
getAutoStdxDir: () => stdxDownloader.getLatestInstalledDir(),
getUserDataDir: () => app.getPath('userData'),
logger
})
registerCangjieDocIpc(ipcMain, {
net,
logger,
getSdkRoot: () => _getCjHome()
})
registerStdxDocIpc(ipcMain, {
net,
logger,
cacheDir: path.join(app.getPath('userData'), 'stdx-docs')
})
registerCourseContentIpc(ipcMain, {
net,
logger,
getSnapshotDir: () => app.isPackaged
? path.join(process.resourcesPath, 'course')
: path.join(__dirname, 'cangjie-course'),
cacheDir: path.join(app.getPath('userData'), 'cangjie-course')
})
registerContactQrcodeIpc(ipcMain, {
net,
logger,
getBundledFile: () => app.isPackaged
? path.join(process.resourcesPath, 'qrcode', 'wangpeng_wx.jpg')
: path.join(__dirname, 'qrcode', 'wangpeng_wx.jpg'),
cacheDir: path.join(app.getPath('userData'), 'contact-qrcode')
})
const cangjieDebugManager = new CangjieDebugManager({
getSdkRoot: () => _getCjHome(),
getBasePath: () => _getFreshPath(),
processLifecycle,
send: (webContentsId, payload) => {
const win = windows.get(webContentsId)
if (win) safeSend(win.webContents, 'cangjie-debug-event', payload)
}
})
// 辅助函数:安全地向 webContents 发送消息
function safeSend(webContents, channel, ...args) {
try {
if (webContents && !webContents.isDestroyed()) {
webContents.send(channel, ...args)
}
} catch (e) {
// 忽略已销毁的 webContents
}
}
// ── 启动引导:首次打开仓颉项目时通知渲染端 ──
const guideFlagPath = () => path.join(app.getPath('userData'), 'guide-shown.flag')
function checkAndNotifyGuide(webContents) {
try {
if (fs.existsSync(guideFlagPath())) return
safeSend(webContents, 'guide-check', { shouldShow: true })
} catch {
// 读取失败不阻塞
}
}
ipcMain.handle('guide-mark-shown', () => {
try {
fs.writeFileSync(guideFlagPath(), '1', 'utf8')
} catch {
// 写入失败不阻塞
}
return { ok: true }
})
function hashCjpmContent(filePath) {
const content = fs.readFileSync(filePath)
return crypto.createHash('sha256').update(content).digest('hex')
}
function stopProjectWatcher(webContentsId) {
const state = projectWatchers.get(webContentsId)
if (!state) return
if (state.refreshTimer) {
clearTimeout(state.refreshTimer)
}
if (state.gitRefreshTimer) {
clearTimeout(state.gitRefreshTimer)
}
if (state.lspTimer) {
clearTimeout(state.lspTimer)
}
try {
state.watcher.close()
} catch (error) {
logger.warn('Failed to close project watcher', { webContentsId, error: error.message })
}
projectWatchers.delete(webContentsId)
}
function startProjectWatcher(webContents, projectPath) {
if (!webContents || webContents.isDestroyed()) return
const webContentsId = webContents.id
stopProjectWatcher(webContentsId)
let resolvedPath
try {
resolvedPath = path.resolve(projectPath)
if (!fs.statSync(resolvedPath).isDirectory()) return
} catch (error) {
return
}
// cjpm.toml 内容哈希(normalizeBuildPath -> sha256):重启前比对,
// watcher 误触发但内容没变时不杀 LSPServer
const cjpmHashes = new Map()
try {
const rootCjpm = path.join(resolvedPath, 'cjpm.toml')
if (fs.existsSync(rootCjpm)) {
cjpmHashes.set(
normalizeBuildPath(rootCjpm),
hashCjpmContent(rootCjpm)
)
}
} catch (error) { /* 初始哈希失败不阻塞监听 */ }
const state = {
watcher: null,
refreshTimer: null,
gitRefreshTimer: null,
lspTimer: null,
changedPaths: new Set(),
lspChanges: new Map(),
cjpmHashes,
projectPath: resolvedPath
}
const handleChange = (eventType, filename) => {
const changedPath = filename ? String(filename).replace(/\\/g, '/') : ''
const resolvedChangedPath = changedPath
? path.resolve(state.projectPath, changedPath)
: ''
if (changedPath) {
markCangjieBuildInputsChanged(
webContentsId,
[resolvedChangedPath]
)
} else {
const buildStates = getWindowBuildStates(webContentsId)
if (buildStates) {
for (const buildState of buildStates.values()) {
markBuildStateChanged(
buildState,
[buildState.workDir]
)
}
}
}
const isGitMetadata = changedPath
.split('/')
.some(segment => segment === '.git')
if (!isGitMetadata) {
state.changedPaths.add(changedPath)
if (state.gitRefreshTimer) clearTimeout(state.gitRefreshTimer)
state.gitRefreshTimer = setTimeout(() => {
state.gitRefreshTimer = null
const changedPaths = Array.from(state.changedPaths)
state.changedPaths.clear()
safeSend(webContents, 'project-files-changed', {
projectPath: state.projectPath,
changedPaths
})
}, 100)
}
// 内容修改不影响项目结构,只处理新增、删除和重命名。
const pathSegments = changedPath.split('/').filter(Boolean)
const isIgnoredLspPath = pathSegments.some(segment => {
return (
segment === '.git' ||
ignoredBuildDirectories.has(segment.toLowerCase())
)
})
const fileName = path.basename(changedPath).toLowerCase()
const isCangjieWorkspaceInput = (
/\.cj$/i.test(changedPath) ||
fileName === 'cjpm.toml' ||
fileName === 'cjpm.lock'
)
if (
resolvedChangedPath &&
!isIgnoredLspPath &&
isCangjieWorkspaceInput
) {
const type = eventType === 'change'
? LSP_FILE_CHANGE_TYPE.CHANGED
: (
fs.existsSync(resolvedChangedPath)
? LSP_FILE_CHANGE_TYPE.CREATED
: LSP_FILE_CHANGE_TYPE.DELETED
)
state.lspChanges.set(
normalizeBuildPath(resolvedChangedPath),
{ filePath: resolvedChangedPath, type }
)
if (state.lspTimer) clearTimeout(state.lspTimer)
state.lspTimer = setTimeout(() => {
state.lspTimer = null
const changes = Array.from(state.lspChanges.values())
state.lspChanges.clear()
// cjpm.toml 是否真要重启:读文件做内容哈希比对。watcher 在
// Windows 上会因访问时间/编辑器临时文件等误触发 change,
// 内容没变就不重启(也不通知服务器)
let restart = false
let effectiveChanges = changes
for (const change of changes) {
if (
path.basename(change.filePath).toLowerCase() !== 'cjpm.toml'
) {
continue
}
const hashKey = normalizeBuildPath(change.filePath)
if (change.type === LSP_FILE_CHANGE_TYPE.CHANGED) {
let nextHash = ''
try {
nextHash = hashCjpmContent(change.filePath)
} catch (error) {
nextHash = ''
}
const previousHash = state.cjpmHashes.get(hashKey) || ''
if (nextHash && nextHash === previousHash) {
// 内容未变:剔除该事件,不重启
effectiveChanges = effectiveChanges.filter(
item => item !== change
)
continue
}
if (nextHash) state.cjpmHashes.set(hashKey, nextHash)
restart = true
} else {
// 新建/删除 cjpm.toml:更新哈希并重启。
// 注意:编辑器保存是"临时文件+原子替换",watcher 会报 add(CREATED)
// 而非 change,因此这里同样要做内容哈希比对——内容没变就不重启、
// 不弹同步提示条。
const previousHash = state.cjpmHashes.get(hashKey) || ''
state.cjpmHashes.delete(hashKey)
if (change.type === LSP_FILE_CHANGE_TYPE.CREATED) {
let nextHash = ''
try {
nextHash = hashCjpmContent(change.filePath)
state.cjpmHashes.set(hashKey, nextHash)
} catch (error) { /* 文件刚建又不可读时忽略 */ }
if (nextHash && nextHash === previousHash) {
effectiveChanges = effectiveChanges.filter(
item => item !== change
)
continue
}
}
restart = true
}
}
// cjpm.toml 内容哈希在上方循环中已确认变化,或 cjpm.lock 变化
// 导致依赖状态可能过期。通知渲染进程显示同步提示条。
const hasWorkspaceConfigChange = effectiveChanges.some(change => {
const name = path.basename(change.filePath).toLowerCase()
return name === 'cjpm.toml' || name === 'cjpm.lock'
})
if (restart || hasWorkspaceConfigChange) {
const hashKey = normalizeBuildPath(
path.join(state.projectPath, 'cjpm.toml')
)
const latestHash = state.cjpmHashes.get(hashKey) || ''
safeSend(webContents, 'cjpm-toml-changed', {
projectPath: state.projectPath,
contentHash: latestHash
})
}
if (!restart && effectiveChanges.length === 0) return
cangjieLspManager.notifyWorkspaceFilesChanged(
webContentsId,
state.projectPath,
effectiveChanges,
{ restart }
).catch(error => {
logger.warn('cangjie-lsp-workspace-change-failed', {
webContentsId,
message: error?.message || String(error)
})
})
}, 120)
}
if (eventType !== 'rename') return
if (pathSegments.some(segment => {
return segment.startsWith('.') || segment === 'node_modules'
})) {
return
}
if (state.refreshTimer) clearTimeout(state.refreshTimer)
state.refreshTimer = setTimeout(() => {
state.refreshTimer = null
safeSend(webContents, 'project-tree-changed', {
projectPath: state.projectPath
})
}, 500)
}
try {
state.watcher = fs.watch(
resolvedPath,
{ recursive: process.platform === 'win32' || process.platform === 'darwin' },
handleChange
)
} catch (error) {
try {
state.watcher = fs.watch(resolvedPath, handleChange)
} catch (fallbackError) {
return
}
}
state.watcher.on('error', () => {
stopProjectWatcher(webContentsId)
})
projectWatchers.set(webContentsId, state)
}
// 辅助函数:从 event.sender 获取对应的 BrowserWindow
function getWindowFromEvent(event) {
return BrowserWindow.fromWebContents(event.sender)
}
function createSplashWindow(appIcon) {
const splash = new BrowserWindow({
width: 760,
height: 460,
useContentSize: true,
frame: false,
transparent: true,
backgroundColor: '#00000000',
show: false,
resizable: false,
maximizable: false,
minimizable: false,
fullscreenable: false,
alwaysOnTop: true,
skipTaskbar: true,
hasShadow: true,
webPreferences: {
// 与主窗口一致沿用安全基线(沙箱开启):splash 仅加载本地可信 splash.html,
// 其 preload.js 只用 contextBridge/ipcRenderer(沙箱兼容,主窗口已实证),
// 无需此前对启动窗关闭沙箱的降级——那层沙箱隔离本是可省的纵深防御。
...createSecureWebPreferences(__dirname),
devTools: false
},
icon: appIcon.isEmpty() ? APP_ICON_PATH : appIcon
})
if (!appIcon.isEmpty()) splash.setIcon(appIcon)
splash.setMenuBarVisibility(false)
splash.center()
attachWindowSecurity(splash)
splash.once('ready-to-show', () => {
if (!splash.isDestroyed()) splash.show()
})
splash.loadFile('splash.html').catch(() => {
if (!splash.isDestroyed()) splash.destroy()
})
return splash
}
function createStartupController(win, splash) {
const startedAt = Date.now()
const minimumVisibleMs = 1100
const transitionMs = 180
let completed = false
let disposed = false
let readyTimer = null
let closeTimer = null
const update = payload => {
if (!splash.isDestroyed()) {
safeSend(splash.webContents, 'startup-status', payload)
}
}
const showWorkbench = () => {
startupControllers.delete(win.webContents.id)
if (!win.isDestroyed()) {
win.show()
win.focus()
}
if (!splash.isDestroyed()) splash.destroy()
}
const complete = (fallback = false) => {
if (completed || disposed) return
completed = true
clearTimeout(controller.fallbackTimer)
update({
phase: 'editor',
progress: 100,
label: fallback ? '正在打开 CIDE' : 'CIDE 就绪',
ready: true
})
const remaining = Math.max(
0,
minimumVisibleMs - (Date.now() - startedAt)
)
readyTimer = setTimeout(() => {
update({
phase: 'editor',
progress: 100,
label: fallback ? 'Opening CIDE' : 'CIDE Ready',
ready: true,
closing: true
})
closeTimer = setTimeout(showWorkbench, transitionMs)
}, remaining)
}
const dispose = () => {
if (disposed) return
disposed = true
clearTimeout(controller.fallbackTimer)
clearTimeout(readyTimer)
clearTimeout(closeTimer)
startupControllers.delete(win.webContents.id)
if (!splash.isDestroyed()) splash.destroy()
}
const controller = {
update,
complete,
dispose,
fallbackTimer: null
}
controller.fallbackTimer = setTimeout(() => complete(true), 15000)
controller.fallbackTimer.unref?.()
splash.webContents.once('did-finish-load', () => {
update({
phase: 'core',
progress: 20,
label: '正在启动核心服务'
})
})
splash.once('closed', () => {
if (completed || disposed) return
completed = true
clearTimeout(controller.fallbackTimer)
showWorkbench()
})
return controller
}
function createWindow(projectPath, options = {}) {
const activeProjectPath =
workspaceSessionStore.normalizeProjectPath(projectPath)
const hasProject = Boolean(activeProjectPath)
const appIcon = nativeImage.createFromPath(APP_ICON_PATH)
const showStartupSplash =
options.showStartupSplash === true &&
process.env.CIDE_DISABLE_SPLASH !== '1'
const splash = showStartupSplash
? createSplashWindow(appIcon)
: null
const win = new BrowserWindow({
width: hasProject ? 1200 : 960,
height: hasProject ? 800 : 650,
minWidth: 800,
minHeight: 450,
titleBarStyle: 'hidden',
frame: false,
show: !showStartupSplash,
webPreferences: {
// 打包版本对用户彻底关闭 DevTools(含 F12 快捷键入口);
// 开发模式(npm start 未打包)保持可用。
...createSecureWebPreferences(__dirname),
devTools: !app.isPackaged
},
icon: appIcon.isEmpty() ? APP_ICON_PATH : appIcon
})
if (!appIcon.isEmpty()) win.setIcon(appIcon)
applyWindowsTaskbarBranding(win)
attachWindowSecurity(win)
const wcId = win.webContents.id
windows.set(wcId, win)
if (activeProjectPath) {
registerProjectRoot(wcId, activeProjectPath)
// 启动时自动打开的工程(命令行参数/上次会话)也记入最近项目
workspaceSessionStore.recordRecentProject(activeProjectPath)
}
const startupController = splash
? createStartupController(win, splash)
: null
if (startupController) {
startupControllers.set(wcId, startupController)
}
const sendWindowState = () => {
safeSend(win.webContents, 'window-maximized-changed', {
maximized: win.isMaximized()
})
}
// Enable F12 / Ctrl+Shift+I to open DevTools —— 仅限开发模式,
// 打包版本(app.isPackaged)不注册该快捷键,用户无法打开 DevTools。
if (!app.isPackaged) {
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
win.webContents.toggleDevTools()
}
})
}
win.on('close', event => {
if (approvedWindowCloses.has(win)) {
approvedWindowCloses.delete(win)
pendingWindowCloses.delete(win)
return
}
if (win.webContents.isLoadingMainFrame()) return
event.preventDefault()
if (pendingWindowCloses.has(win)) return
const requestId = ++nextWindowCloseRequestId
pendingWindowCloses.set(win, requestId)
safeSend(win.webContents, 'window-close-requested', { requestId })
})
win.on('closed', () => {
startupController?.dispose()
pendingWindowCloses.delete(win)
stopProjectWatcher(wcId)
rendererCrashGuard.cleanup(wcId)
projectTreeScanner.cancelOwner(wcId, 'window-closed')
projectSearchService.cancelOwner(wcId, 'window-closed')
cangjieWorkspaceIndexer.cancelOwner(wcId, 'window-closed')
clearCangjieBuildStates(wcId)
clearWebContentsRoots(wcId)
windows.delete(wcId)
cangjieLspManager.stopForWebContents(wcId)
cangjieDebugManager.stopForWebContents(wcId).catch(() => { })
// 清理该窗口关联的服务进程
serviceRun.cleanupWindowServices(wcId)
processLifecycle.stopOwner(wcId, 'window-closed')
// 更新菜单引用
updateMenuForFocusedWindow()
})
win.on('focus', () => {
updateMenuForFocusedWindow()
})
win.on('maximize', sendWindowState)
win.on('unmaximize', sendWindowState)
win.webContents.on('dom-ready', () => {
startupController?.update({
phase: 'workspace',
progress: 48,
label: '正在加载工作区'
})
})
win.webContents.on('did-finish-load', () => {
sendWindowState()
startupController?.update({
phase: 'editor',
progress: 76,
label: '正在初始化编辑器'
})
// 如果传入了项目路径,在页面加载完成后发送
if (activeProjectPath) {
startProjectWatcher(win.webContents, activeProjectPath)
safeSend(
win.webContents,
'project-opened',
activeProjectPath
)
// 首次打开仓颉项目时弹出引导
checkAndNotifyGuide(win.webContents)
}
})
win.webContents.on(
'did-fail-load',
(_event, _errorCode, _description, _url, isMainFrame) => {
if (isMainFrame) startupController?.complete(true)
}
)
// 渲染进程崩溃/OOM:自动重载并经 recovery 恢复;短时间反复崩溃则放弃重载
rendererCrashGuard.attach(win, { logger, dialog })
// 有项目直接进工作台;无项目直接进 Welcome 页(避免先加载整个工作台再跳转)。
// 例外:e2e 自动化(CIDE_E2E_USER_DATA)始终加载工作台壳——用例依赖无项目时
// 也能直接操作 #menu-bar 并经 open-project-current-window 在壳内打开项目。
const showWelcomePage = !hasProject && !process.env.CIDE_E2E_USER_DATA
win.loadFile(showWelcomePage ? 'welcome.html' : 'index.html')
// 设置菜单(基于当前聚焦窗口)
updateMenuForFocusedWindow()
return win
}
ipcMain.on('renderer-ready', event => {
startupControllers.get(event.sender.id)?.complete()
})
// Renderer主动查询Python状态(避免推送时序问题)
ipcMain.handle('get-python-status', async () => {
// 确保检查已完成
return await projectCreation.checkPythonForDebugger()
})
function getFocusedWindow() {
return BrowserWindow.getFocusedWindow() || Array.from(windows.values())[windows.size - 1] || null
}
function updateMenuForFocusedWindow() {
const focusedWin = getFocusedWindow()
const menu = Menu.buildFromTemplate([
{
label: '文件',
submenu: [
{
label: '新建项目', click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'new-project')
}
},
{ label: '打开项目', click: () => openProject() },
{
label: '关闭项目', click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'close-project')
}
},
{ type: 'separator' },
{
label: '设置', click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'settings')
}
},
{ type: 'separator' },
{
label: '退出', click: () => {
const win = getFocusedWindow()
if (win) {
safeSend(win.webContents, 'menu-action', 'quit')
} else {
app.quit()
}
}
}
]
},
{
label: '编辑',
submenu: [
{ label: '撤销', accelerator: 'CmdOrCtrl+Z' },
{ label: '重做', accelerator: 'CmdOrCtrl+Shift+Z' },
{ type: 'separator' },
{ label: '剪切', accelerator: 'CmdOrCtrl+X' },
{ label: '复制', accelerator: 'CmdOrCtrl+C' },
{ label: '粘贴', accelerator: 'CmdOrCtrl+V' }
]
},
{
label: '运行',
submenu: [
{
label: '运行', click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'run')
}, accelerator: 'CmdOrCtrl+R'
},
{
label: '调试', click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'debug')
}
}
]
},
{
label: '查看',
submenu: [
{
label: '切换项目面板', click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'toggle-project')
}
},
{
label: '切换终端', click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'toggle-terminal')
}
},
{
label: '切换侧边栏', click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'toggle-sidebar')
}
}
]
},
{
label: '帮助',
submenu: [
{
label: '检查更新',
click: () => {
const win = getFocusedWindow()
if (win) safeSend(win.webContents, 'menu-action', 'check-updates')
}
},
{ type: 'separator' },
{
label: '打开日志文件夹',
click: () => {
try {
const openError = shell.openPath(logger.getDirectory())
if (openError) {
logger.warn('open-logs-folder-failed', { error: openError })
}
} catch (error) {
logger.error('open-logs-folder-failed', {
error: error.message,
stack: error.stack
})
}
}
},
{ type: 'separator' },
{ label: '关于', click: () => showAbout() }
]
}
])
Menu.setApplicationMenu(menu)
}
function openProject() {
const focusedWin = getFocusedWindow()
if (focusedWin) {
safeSend(focusedWin.webContents, 'show-project-picker')
}
}
// 获取示例项目列表
ipcMain.handle('get-example-projects', async () => {
try {
const examplesDir = path.join(app.getAppPath(), 'examples')
if (!fs.existsSync(examplesDir)) {
return []
}
const entries = fs.readdirSync(examplesDir, { withFileTypes: true })
const examples = []
for (const entry of entries) {
if (!entry.isDirectory()) continue
const examplePath = path.join(examplesDir, entry.name)
const cjpmTomlPath = path.join(examplePath, 'cjpm.toml')
// 检查是否是有效的仓颉项目(有 cjpm.toml)
if (!fs.existsSync(cjpmTomlPath)) continue
// 读取 cjpm.toml 获取项目名称和描述
let name = entry.name
let description = ''
try {
const content = fs.readFileSync(cjpmTomlPath, 'utf8')
// 简单解析:获取 name 和 description 字段
const nameMatch = content.match(/name\s*=\s*"([^"]+)"/)
const descMatch = content.match(/description\s*=\s*"([^"]*)"/)
if (nameMatch) name = nameMatch[1]
if (descMatch) description = descMatch[1]
} catch {
// 读取失败,使用默认值
}
examples.push({
path: examplePath,
name: name,
description: description
})
}
return examples
} catch (error) {
logger.error('Failed to load example projects', { error: error.message })
return []
}
})
// 通过路径打开项目的共用实现(用于示例项目 / Welcome 页面 / index.html?project= 跳转)
function openProjectPathInWebContents(sender, projectPath) {
const activeProjectPath = workspaceSessionStore.normalizeProjectPath(projectPath)
if (!activeProjectPath) {
safeSend(sender, 'project-scanned', {
success: false,
path: String(projectPath || ''),
source: 'open',
error: '项目文件夹不存在或无法访问。'
})
return false
}
cangjieLspManager.stopForWebContents(sender.id)
clearCangjieBuildStates(sender.id)
workspaceSessionStore.saveLastProject(activeProjectPath)
workspaceSessionStore.recordRecentProject(activeProjectPath)
registerProjectRoot(sender.id, activeProjectPath)
// 页面加载完成后再推送 project-opened——否则监听器还没挂上,消息会丢失
const notifyProjectReady = () => {
if (sender.isDestroyed()) return
startProjectWatcher(sender, activeProjectPath)
safeSend(sender, 'project-opened', activeProjectPath)
// 首次打开仓颉项目时弹出引导
checkAndNotifyGuide(sender)
}
// 从 Welcome 打开项目时,恢复关闭项目前保存的窗口尺寸/最大化状态
const openWin = windows.get(sender.id)
const savedState = windowStates.get(sender.id)
if (savedState && openWin && !openWin.isDestroyed()) {
if (savedState.isMaximized) {
openWin.maximize()
} else {
openWin.setBounds(savedState.bounds)
}
windowStates.delete(sender.id)
}
// 已在工作台页:直接推送;在 Welcome 页:先导航到工作台,did-finish-load 后再推送
if (!sender.isDestroyed() && sender.getURL().includes('index.html')) {
notifyProjectReady()
} else if (!sender.isDestroyed()) {
sender.once('did-finish-load', notifyProjectReady)
sender.loadFile('index.html')
}
return true
}
ipcMain.on('open-project-path', (event, projectPath) => {
openProjectPathInWebContents(event.sender, projectPath)
})
// 创建新临时项目(用于 Welcome 页面的 New Project)
ipcMain.handle('create-new-project', async () => {
try {
const tempDir = path.join(os.tmpdir(), `cide-project-${Date.now()}`)
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true })
// 字段必须完整,否则 cjpm 1.1.3 报 cjc-version/output-type 错误
fs.writeFileSync(
path.join(tempDir, 'cjpm.toml'),
`[package]
cjc-version = "1.1.3"
name = "untitled"
organization = ""
description = ""
version = "0.1.0"
target-dir = ""
script-dir = ""
output-type = "executable"
compile-option = ""
override-compile-option = ""
link-option = ""
package-configuration = {}
`,
'utf8'
)
fs.writeFileSync(
path.join(tempDir, 'src', 'main.cj'),
`package untitled
main(): Int64 {
println("Hello, Cangjie!")
return 0
}
`,
'utf8'
)
return { success: true, path: tempDir }
} catch (error) {
logger.error('Failed to create new project', { error: error.message })
return { success: false, error: String(error) }
}
})
// 关闭当前项目(回到 Welcome 页面)
ipcMain.on('close-project', (event) => {
const webContents = event.sender
if (webContents.isDestroyed()) return
// 如果当前已经在 Welcome 页面,直接返回(不需要关闭项目)
const currentUrl = webContents.getURL()
if (currentUrl.includes('welcome.html')) {
return
}
// 多实例逻辑:检查窗口数量
const windowCount = windows.size
const win = getWindowFromEvent(event)
if (windowCount <= 1) {
// 单实例:显示 Welcome 页面,不关闭窗口,缩小窗口
// 仅清 lastProjectPath(下次启动不再自动进项目),保留 recentProjects
workspaceSessionStore.clearLastProject()
// 保存当前窗口状态(最大化/大小/位置)
const currentState = {
isMaximized: win.isMaximized(),
bounds: win.getBounds()
}
windowStates.set(webContents.id, currentState)
cangjieLspManager.stopForWebContents(webContents.id)
clearCangjieBuildStates(webContents.id)
stopProjectWatcher(webContents.id)
clearWebContentsRoots(webContents.id)
if (!webContents.isDestroyed() && win) {
win.setSize(960, 650)
win.webContents.loadFile('welcome.html')
}
} else {
// 多实例:关闭当前窗口,显示剩余实例
const win = getWindowFromEvent(event)
if (win && !win.isDestroyed()) {
// 先清理资源
cangjieLspManager.stopForWebContents(webContents.id)
clearCangjieBuildStates(webContents.id)
stopProjectWatcher(webContents.id)
clearWebContentsRoots(webContents.id)
// 标记为已批准关闭
approvedWindowCloses.add(win)
win.close()
}
}
})
ipcMain.on('open-project-current-window', (event, projectPath) => {
const activeProjectPath =
workspaceSessionStore.normalizeProjectPath(projectPath)
if (!activeProjectPath) {
safeSend(event.sender, 'project-scanned', {
success: false,
path: String(projectPath || ''),
source: 'open',
error: '项目文件夹不存在或无法访问。'
})
return
}
cangjieLspManager.stopForWebContents(event.sender.id)
clearCangjieBuildStates(event.sender.id)
workspaceSessionStore.saveLastProject(activeProjectPath)
workspaceSessionStore.recordRecentProject(activeProjectPath)
registerProjectRoot(event.sender.id, activeProjectPath)
startProjectWatcher(event.sender, activeProjectPath)
// 恢复之前保存的窗口状态(从 Welcome 页面打开项目时)
const savedState = windowStates.get(event.sender.id)
if (savedState) {
const openWin = getWindowFromEvent(event)
if (openWin && !openWin.isDestroyed()) {
if (savedState.isMaximized) {
openWin.maximize()
} else {
openWin.setBounds(savedState.bounds)
}
}
windowStates.delete(event.sender.id)
}
safeSend(event.sender, 'project-opened', activeProjectPath)
// 首次打开仓颉项目时弹出引导(通过 guide-shown.flag 判断是否已看过)
checkAndNotifyGuide(event.sender)
})
ipcMain.on('open-project-new-window', (event, projectPath) => {
const activeProjectPath =
workspaceSessionStore.normalizeProjectPath(projectPath)
if (!activeProjectPath) {
safeSend(event.sender, 'project-scanned', {
success: false,
path: String(projectPath || ''),
source: 'open',
error: 'The project folder does not exist or cannot be accessed.'
})
return
}
workspaceSessionStore.saveLastProject(activeProjectPath)
workspaceSessionStore.recordRecentProject(activeProjectPath)
createWindow(activeProjectPath)
})
function showAbout() {
const focusedWin = getFocusedWindow()
if (focusedWin) {
safeSend(focusedWin.webContents, 'show-about-dialog')
}
}
// ============== 窗口操作 IPC - 使用 event.sender 定位窗口 ==============
ipcMain.on('minimize', (event) => {
const win = getWindowFromEvent(event)
logger.info(`[IPC] minimize: win=${win ? 'exists' : 'null'}, destroyed=${win?.isDestroyed()}`)
if (win && !win.isDestroyed()) {
logger.info('[IPC] minimize: calling win.minimize()')
win.minimize()
logger.info('[IPC] minimize: win.minimize() called successfully')
} else {
logger.error('[IPC] minimize: window is null or already destroyed!')
}
})
ipcMain.on('quit', (event) => {
const win = getWindowFromEvent(event)
logger.info(`[IPC] quit: win=${win ? 'exists' : 'null'}, approved=${win ? approvedWindowCloses.has(win) : 'N/A'}, destroyed=${win?.isDestroyed()}`)
if (win && !win.isDestroyed()) {
logger.info('[IPC] quit: adding to approvedWindowCloses and calling win.close()')
approvedWindowCloses.add(win)
logger.info('[IPC] quit: added to approved list, calling close()')
win.close()
logger.info('[IPC] quit: close() called successfully')
} else {
logger.error('[IPC] quit: window is null or already destroyed!')
}
})
ipcMain.on('show-settings', (event) => {
const win = getWindowFromEvent(event)
if (win && !win.isDestroyed()) {
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
safeSend(win.webContents, 'menu-action', 'show-settings')
}
})
win.webContents.loadFile('index.html')
}
})
ipcMain.on('maximize', (event) => {
const win = getWindowFromEvent(event)
if (win) {
if (win.isMaximized()) {
win.unmaximize()
} else {
win.maximize()
}
}
})
ipcMain.on('close', (event) => {
const win = getWindowFromEvent(event)
if (!win) return
pendingWindowCloses.delete(win)
approvedWindowCloses.add(win)
win.close()
})
ipcMain.on('window-close-response', (event, payload = {}) => {
const win = getWindowFromEvent(event)
if (!win) return
const pendingRequestId = pendingWindowCloses.get(win)
if (
pendingRequestId === undefined ||
payload.requestId !== pendingRequestId
) {
return
}
pendingWindowCloses.delete(win)
if (payload.approved !== true) return
approvedWindowCloses.add(win)
win.close()
})
ipcMain.on('get-directory', async (event, dirPath) => {
try {
if (!dirPath || typeof dirPath !== 'string') {
event.reply('directory-content', [])
return
}
const resolved = path.resolve(dirPath)
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
event.reply('directory-content', [])
return
}
const files = fs.readdirSync(resolved, { withFileTypes: true })
const tree = files.map(file => ({
name: file.name,
isDirectory: file.isDirectory(),
path: path.join(resolved, file.name)
}))
event.reply('directory-content', tree)
} catch (error) {
event.reply('directory-content', [])
}
})
ipcMain.on('scan-project', async (event, request) => {
const projectPath = typeof request === 'string'
? request
: request && request.projectPath
const source = typeof request === 'object' && request
? request.source || ''
: ''
if (!projectPath) return
// 工程扫描必然意味着该窗口打开了此工程:登记为路径围栏授权根
registerProjectRoot(event.sender.id, projectPath)
try {
const result = await projectTreeScanner.scan(projectPath, {
ownerId: event.sender.id
})
safeSend(event.sender, 'project-scanned', {
success: true,
name: path.basename(projectPath),
path: projectPath,
children: result.children,
stats: result.stats,
source
})
} catch (error) {
if (error?.code === 'PROJECT_SCAN_CANCELLED') return
logger.error('project-tree-scan', error, {
projectPath,
webContentsId: event.sender.id
})
safeSend(event.sender, 'project-scanned', {
success: false,
name: path.basename(projectPath),
path: projectPath,
error: error.message,
errorCode: error.code || 'PROJECT_SCAN_FAILED',
source
})
}
})
registerEditorFileIpc(ipcMain, {
markCangjieBuildInputsChanged
})
ipcMain.on('open-project', event => {
const sender = event.sender
// Welcome 页没有内嵌项目选择器:先进入工作台壳,加载完再弹选择器
if (!sender.isDestroyed() && !sender.getURL().includes('index.html')) {
sender.once('did-finish-load', () => {
if (!sender.isDestroyed()) {
safeSend(sender, 'show-project-picker')
}
})
sender.loadFile('index.html')
return
}
safeSend(sender, 'show-project-picker')
})
registerProjectPickerIpc(ipcMain, { safeSend, shell })
ipcMain.on('new-project', (event) => {
safeSend(event.sender, 'menu-action', 'new-project')
})
ipcMain.on('run', (event) => {
safeSend(event.sender, 'menu-action', 'run')
})
ipcMain.on('debug', (event) => {
safeSend(event.sender, 'menu-action', 'debug')
})
ipcMain.on('about', () => {
showAbout()
})
registerTreeOperationsIpc(ipcMain, { shell })
// ============== 服务面板(模块化) ==============
serviceRun.registerServiceIpc(ipcMain)
registerCangjieDebugIpc(ipcMain, { cangjieDebugManager, safeSend })
registerCangjieHeapIpc(ipcMain, {
loadSettings,
safeSend,
getServicePid: (wcId, serviceId) => {
const procs = serviceRun.getServiceProcesses()
const key = `${wcId}:${serviceId}`
return procs[key]?.pid || null
}
})
// ============== 设置 IPC ==============
function _validateStdxLibPath(dir) {
if (!dir) return { valid: false, resolved: '' }
try {
const fs = require('fs')
const path = require('path')
const stat = fs.statSync(dir)
if (!stat.isDirectory()) return { valid: false, resolved: '' }
// 递归扫一层看有没有 .cjo(cjo-catalog-core 会扫更深,这里只做快速判断)
const dirs = [dir]
for (let depth = 0; depth < 2 && dirs.length; depth++) {
const entries = fs.readdirSync(dirs.shift(), { withFileTypes: true })
for (const e of entries) {
if (e.isFile() && /\.cjo$/i.test(e.name)) {
return { valid: true, resolved: path.resolve(dir) }
}
if (e.isDirectory()) dirs.push(path.join(dir, e.name))
}
}
return { valid: false, resolved: path.resolve(dir) }
} catch (_) {
return { valid: false, resolved: '' }
}
}
function loadSettingsForRenderer() {
const settings = loadSettings()
const configuredPath = String(settings.cjHome || '').trim()
const resolved = _resolveCangjieSdk(configuredPath)
const configuredGitPath = String(settings.gitPath || '').trim()
const resolvedGit = resolveGitInstallation({
configuredPath: configuredGitPath
})
const stdxCheck = _validateStdxLibPath(String(settings.stdxLibPath || '').trim())
return {
...settings,
cjHome: configuredPath || resolved.path,
cjHomeResolved: resolved.path,
cjHomeSource: resolved.source,
cjHomeValid: configuredPath
? isCangjieSdkRoot(configuredPath)
: Boolean(resolved.path),
stdxLibPathResolved: stdxCheck.resolved,
stdxLibPathValid: stdxCheck.valid,
gitPath: configuredGitPath || resolvedGit.path,
gitPathResolved: resolvedGit.path,
gitPathSource: resolvedGit.source,
gitPathValid: configuredGitPath
? Boolean(findGitExecutable(configuredGitPath))
: Boolean(resolvedGit.path),
opensslPathResolved: opensslRuntime.getOpenSSLStatus().dir,
opensslPathSource: opensslRuntime.getOpenSSLStatus().source,
opensslPathVersion: opensslRuntime.getOpenSSLStatus().version,
opensslPathValid: opensslRuntime.getOpenSSLStatus().available,
stdxAutoDir: stdxDownloader.getLatestInstalledDir()
}
}
ipcMain.on('settings-load', (event) => {
safeSend(event.sender, 'settings-loaded', loadSettingsForRenderer())
})
// ============== OpenSSL 下载安装 IPC ==============
ipcMain.handle('openssl-download-list', async () => ({
distros: opensslDownloader.OPENSSL_DISTROS,
installed: await opensslDownloader.getInstalledInfo(),
busy: opensslDownloader.isBusy()
}))
ipcMain.handle('openssl-download-start', async (event, payload) => {
const id = payload && typeof payload.id === 'string' ? payload.id : ''
const installDir = payload && typeof payload.installDir === 'string' ? payload.installDir : ''
return opensslDownloader.download({ id, installDir }, event)
})
ipcMain.handle('openssl-download-remove', () => opensslDownloader.removeDownloaded())
// ============== STDX 自动下载安装 IPC ==============
function _resolveSdkVersionFromMain() {
const cjHome = _getCjHome()
if (!cjHome) return ''
const cjc = path.join(cjHome, 'bin', process.platform === 'win32' ? 'cjc.exe' : 'cjc')
if (!fs.existsSync(cjc)) return ''
try {
const { spawnSync } = require('child_process')
const out = spawnSync(cjc, ['--version'], {
windowsHide: true, encoding: 'utf8', timeout: 5000
})
const m = String(out.stdout || '').match(/(\d+\.\d+\.\d+)/)
return m ? m[1] : ''
} catch (_) { return '' }
}
ipcMain.handle('stdx-download-list', async () => {
const sdkVersion = _resolveSdkVersionFromMain()
return stdxDownloader.getAvailable({ sdkVersion })
})
ipcMain.handle('stdx-download-start', async (event, payload) => {
const tag = payload && typeof payload.tag === 'string' ? payload.tag : ''
const installDir = payload && typeof payload.installDir === 'string' ? payload.installDir.trim() : ''
if (!tag) return { success: false, error: 'Missing tag' }
if (!installDir) return { success: false, error: '请先选择 STDX 的安装目录' }
return stdxDownloader.download({ tag, installDir }, event)
})
ipcMain.handle('stdx-download-remove', async (_event, payload) => {
const tag = payload && typeof payload.tag === 'string' ? payload.tag : ''
if (!tag) return { success: false, error: 'Missing tag' }
return stdxDownloader.remove({ tag })
})
// 暴露给 cangjie-cjo-ipc 取自动安装路径
ipcMain.handle('stdx-auto-dir', () => stdxDownloader.getLatestInstalledDir())
ipcMain.handle('settings-get', () => loadSettingsForRenderer())
// ============== 设置落盘前的入口收敛(纵深防御) ==============
// 这些路径/URL 字段在使用处已各有二次校验(git/sdk 走可执行存在性、update 走
// requireHttpsUrl),此处再补一道入口防线:拒绝控制字符(换行可注入下游命令行 /
// 配置解析器)、限制单字段长度(防超大值),URL 类仅接受 http(s)。
// 非法值退回上一已存值或空,绝不因净化而崩溃。
const MAX_SETTING_FIELD_CHARS = 4096
const MAX_SETTING_BLOB_CHARS = 2 * 1024 * 1024
function _sanitizePathLike(raw, fallback) {
const source = typeof raw === 'string' ? raw : String(fallback == null ? '' : fallback)
const trimmed = source.trim()
if (!trimmed) return ''
// 含 NUL/换行等控制字符:视为异常,退回已存值(本身已在既往保存时被净化过)。
if (/[\u0000-\u001f\u007f]/.test(trimmed)) {
return String(fallback == null ? '' : fallback).trim().slice(0, MAX_SETTING_FIELD_CHARS)
}
return trimmed.slice(0, MAX_SETTING_FIELD_CHARS)
}
function _sanitizeHttpUrl(raw, fallback) {
const value = _sanitizePathLike(raw, fallback)
if (!value) return ''
try {
const u = new URL(value)
if (u.protocol === 'http:' || u.protocol === 'https:') return value
} catch (_) { /* 非绝对 URL:视为无效 */ }
return ''
}
ipcMain.on('settings-save', (event, settings) => {
if (!settings || typeof settings !== 'object') return
try {
const current = loadSettings()
const nextSettings = {
...current,
...(settings || {}),
cjHome: _sanitizePathLike(settings?.cjHome, current.cjHome),
stdxLibPath: _sanitizePathLike(settings?.stdxLibPath, current.stdxLibPath),
gitPath: _sanitizePathLike(settings?.gitPath, current.gitPath),
notesPath: _sanitizePathLike(settings?.notesPath, current.notesPath),
opensslInstallDir: _sanitizePathLike(settings?.opensslInstallDir, current.opensslInstallDir),
updateUrl: _sanitizeHttpUrl(settings?.updateUrl, current.updateUrl),
aiApiUrl: _sanitizeHttpUrl(settings?.aiApiUrl, current.aiApiUrl)
}
// 体积封顶:拒绝异常/被注入的超大负载写满磁盘或拖慢每次读取。
if (JSON.stringify(nextSettings).length > MAX_SETTING_BLOB_CHARS) {
logger.warn('Rejected oversized settings payload')
return
}
saveSettings(nextSettings)
// Send merged settings back to renderer to keep _loadedSettings in sync
safeSend(event.sender, 'settings-loaded', loadSettingsForRenderer())
} catch (e) {
logger.warn('Failed to save settings', { error: e.message })
}
})
const gitConflictService = createGitConflictService({
getGitRepositoryRoot,
getGitState,
runGit,
validateGitRelativePath
})
registerGitIpc(ipcMain, {
createGitPullConflictResult,
ensureExpectedGitBranch,
getCurrentGitBranch,
getGitCommitDetail,
getGitCompareFile,
getGitCompareFiles,
getGitCompareRefs,
getGitConflictContent: gitConflictService.getContent,
getGitDiff,
getGitIncomingPaths,
getGitLog,
getGitLogAuthors,
getGitOperationContext,
getGitPushContext,
getGitRepositoryRoot,
getGitState,
runGit,
resolveGitConflict: gitConflictService.resolve,
validateGitRelativePath,
validateGitRemoteTarget
})
// ============== SDK 版本 + 项目创建(模块化) ==============
projectCreation.registerSdkVersionIpc(ipcMain)
projectCreation.registerProjectCreationIpc(ipcMain)
registerTestRunnerIpc(ipcMain, {
safeSend,
logger,
processLifecycle,
sdkManager: cangjieSdkManager
})
registerCangjieFormatFolderIpc(ipcMain, {
safeSend,
logger,
getSdkRoot: () => _getCjHome()
})
registerCjpmDependencyIpc(ipcMain, {
logger,
sdkManager: cangjieSdkManager,
isPathAllowed: isPathAllowedForSender
})
registerTodoScanIpc(ipcMain, {
logger
})
registerRunConfigIpc(ipcMain, {
logger
})
// 单实例锁:防止双开同一项目产生两套 LSPServer/pty 进程,
// 以及 recovery 快照/会话存储被两个实例互相覆盖。
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
app.quit()
} else {
app.on('second-instance', () => {
const existing = getFocusedWindow()
if (!existing || existing.isDestroyed()) return
if (existing.isMinimized()) existing.restore()
existing.show()
existing.focus()
})
app.whenReady().then(() => {
projectCreation.persistAutoDetectedToolPaths()
// 休眠唤醒后 LSPServer 可能已死或假死(管道冻结):对所有 client
// 做一次活性探测,死了再复活
powerMonitor.on('resume', () => {
try {
cangjieLspManager.probeAndRecoverAll()
} catch (error) {
logger.warn('power-monitor-resume-failed', {
message: error?.message || String(error)
})
}
})
createWindow(
workspaceSessionStore.loadLastProject(),
{ showStartupSplash: true }
)
// 启动Python预检(结果会被缓存,handler中await获取)
void projectCreation.checkPythonForDebugger()
app.on('activate', () => {
if (windows.size === 0) {
createWindow(
workspaceSessionStore.loadLastProject(),
{ showStartupSplash: true }
)
}
})
}).catch(err => {
logger.error('app.whenReady failed', { error: err.message, stack: err.stack })
app.exit(1)
})
}
app.on('before-quit', () => {
cangjieLspManager.stopAll()
cangjieDebugManager.stopAll()
projectTreeScanner.cancelAll('application-quit')
projectSearchService.cancelAll('application-quit')
cangjieWorkspaceIndexer.cancelAll('application-quit')
Array.from(projectWatchers.keys()).forEach(stopProjectWatcher)
clearAllBuildStates()
processLifecycle.stopAll('application-quit')
serviceRun.clearAll()
})
registerTerminalIpc(ipcMain, {
processLifecycle,
spawnProcess: spawn,
getEnvironmentPath: _getFreshPath,
getSdkRoot: _getCjHome
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})