Skip to content

Commit b26fe09

Browse files
[v3.0]
1 parent 664c5c9 commit b26fe09

32 files changed

Lines changed: 2408 additions & 576 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ npm run electron:dev
5555
npm run electron:build
5656
```
5757

58+
### 构建 macOS 版本
59+
60+
- macOS 的产物建议通过 GitHub Actions 构建(本仓库已提供 workflow),并输出 dmg/zip(x64/arm64)。
61+
- 推送 tag(例如 v1.0.0)会触发自动构建,产物在 Actions 的 Artifacts 下载。
62+
- 也可以在 macOS 本机执行:
63+
64+
```bash
65+
npm run electron:build:mac
66+
```
67+
5868
## 项目结构
5969

6070
```

electron/main.js

Lines changed: 211 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ const fs = require('fs')
44
const chokidar = require('chokidar')
55
const { spawnSync } = require('child_process')
66

7+
const fileNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
8+
const IS_WINDOWS = process.platform === 'win32'
9+
710
let pty = null
811
try {
912
pty = require('node-pty')
@@ -627,6 +630,71 @@ function getFileType(fileName) {
627630
return typeMap[ext] || 'text'
628631
}
629632

633+
function detectTextMeta(buffer) {
634+
if (!buffer || buffer.length === 0) {
635+
return { encoding: 'utf8', bom: 'none', bomLength: 0 }
636+
}
637+
if (buffer.length >= 3 && buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
638+
return { encoding: 'utf8', bom: 'utf8', bomLength: 3 }
639+
}
640+
if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
641+
return { encoding: 'utf16le', bom: 'utf16le', bomLength: 2 }
642+
}
643+
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
644+
return { encoding: 'utf16be', bom: 'utf16be', bomLength: 2 }
645+
}
646+
return { encoding: 'utf8', bom: 'none', bomLength: 0 }
647+
}
648+
649+
function decodeTextBuffer(buffer) {
650+
const meta = detectTextMeta(buffer)
651+
const body = buffer.slice(meta.bomLength)
652+
if (meta.encoding === 'utf16le') {
653+
return { text: body.toString('utf16le'), meta }
654+
}
655+
if (meta.encoding === 'utf16be') {
656+
const swapped = Buffer.from(body)
657+
if (swapped.length >= 2) swapped.swap16()
658+
return { text: swapped.toString('utf16le'), meta }
659+
}
660+
return { text: body.toString('utf8'), meta }
661+
}
662+
663+
function detectEol(text) {
664+
if (typeof text !== 'string' || text.length === 0) return '\n'
665+
return text.indexOf('\r\n') >= 0 ? '\r\n' : '\n'
666+
}
667+
668+
function normalizeEol(text, eol) {
669+
const normalized = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
670+
if (eol === '\r\n') return normalized.replace(/\n/g, '\r\n')
671+
return normalized
672+
}
673+
674+
function encodeTextBuffer(text, options) {
675+
const encoding = options?.encoding || 'utf8'
676+
const bom = options?.bom || 'none'
677+
const eol = options?.eol || '\n'
678+
const normalizedText = normalizeEol(text, eol)
679+
680+
if (encoding === 'utf16le') {
681+
const body = Buffer.from(normalizedText, 'utf16le')
682+
if (bom === 'utf16le') return Buffer.concat([Buffer.from([0xff, 0xfe]), body])
683+
return body
684+
}
685+
686+
if (encoding === 'utf16be') {
687+
const body = Buffer.from(normalizedText, 'utf16le')
688+
if (body.length >= 2) body.swap16()
689+
if (bom === 'utf16be') return Buffer.concat([Buffer.from([0xfe, 0xff]), body])
690+
return body
691+
}
692+
693+
const body = Buffer.from(normalizedText, 'utf8')
694+
if (bom === 'utf8') return Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), body])
695+
return body
696+
}
697+
630698
// 读取目录结构 - 支持所有文件
631699
function readDirectoryTree(dirPath, basePath = '', maxDepth = null, currentDepth = 0) {
632700
const items = []
@@ -638,9 +706,18 @@ function readDirectoryTree(dirPath, basePath = '', maxDepth = null, currentDepth
638706

639707
try {
640708
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
709+
.filter((entry) => {
710+
if (entry.isDirectory() && (entry.name === 'node_modules' || entry.name === '.git')) return false
711+
return true
712+
})
713+
.sort((a, b) => {
714+
const aIsDir = a.isDirectory()
715+
const bIsDir = b.isDirectory()
716+
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1
717+
return fileNameCollator.compare(a.name, b.name)
718+
})
719+
641720
for (const entry of entries) {
642-
// 跳过隐藏文件和 node_modules
643-
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue
644721

645722
const fullPath = path.join(dirPath, entry.name)
646723
const relativePath = basePath ? path.join(basePath, entry.name) : entry.name
@@ -717,6 +794,24 @@ ipcMain.handle('perf-log', async (event, payload) => {
717794
return { success: true }
718795
})
719796

797+
// 剪贴板(通过主进程读写,避免 preload 中 clipboard 不可用)
798+
ipcMain.on('clipboard-write-text', (event, text) => {
799+
try {
800+
clipboard.writeText(String(text || ''))
801+
} catch (err) {
802+
console.error('写入剪贴板失败:', err.message)
803+
}
804+
})
805+
806+
ipcMain.on('clipboard-read-text', (event) => {
807+
try {
808+
event.returnValue = clipboard.readText()
809+
} catch (err) {
810+
console.error('读取剪贴板失败:', err.message)
811+
event.returnValue = ''
812+
}
813+
})
814+
720815
// 读取目录(限制深度)
721816
ipcMain.handle('read-directory-depth', async (event, payload) => {
722817
const perfStart = perfLogEnabled ? Date.now() : 0
@@ -798,8 +893,18 @@ ipcMain.handle('read-file', async (event, filePath) => {
798893
}
799894
}
800895

801-
const content = fs.readFileSync(filePath, 'utf-8')
802-
return { success: true, content, fileType }
896+
const raw = fs.readFileSync(filePath)
897+
const decoded = decodeTextBuffer(raw)
898+
const content = decoded.text
899+
const eol = detectEol(content)
900+
return {
901+
success: true,
902+
content,
903+
fileType,
904+
encoding: decoded.meta.encoding,
905+
bom: decoded.meta.bom,
906+
eol
907+
}
803908
} catch (err) {
804909
// 静默处理文件不存在错误
805910
if (err.code === 'ENOENT') {
@@ -810,6 +915,67 @@ ipcMain.handle('read-file', async (event, filePath) => {
810915
}
811916
})
812917

918+
// 保存文件(尽量保留原始编码 / BOM / 换行风格)
919+
ipcMain.handle('save-file', async (event, payload) => {
920+
const filePath = payload?.filePath
921+
const content = payload?.content
922+
const options = payload?.options
923+
924+
try {
925+
if (!filePath || typeof filePath !== 'string') {
926+
return { success: false, error: '路径参数无效' }
927+
}
928+
if (typeof content !== 'string') {
929+
return { success: false, error: '内容参数无效' }
930+
}
931+
932+
let effectiveOptions = options
933+
if (!effectiveOptions || !effectiveOptions.encoding || !effectiveOptions.eol || !effectiveOptions.bom) {
934+
try {
935+
if (fs.existsSync(filePath)) {
936+
const raw = fs.readFileSync(filePath)
937+
const decoded = decodeTextBuffer(raw)
938+
effectiveOptions = {
939+
encoding: decoded.meta.encoding,
940+
bom: decoded.meta.bom,
941+
eol: detectEol(decoded.text),
942+
...(effectiveOptions || {})
943+
}
944+
}
945+
} catch {
946+
// ignore probe failures
947+
}
948+
}
949+
950+
const buffer = encodeTextBuffer(content, effectiveOptions || {})
951+
fs.writeFileSync(filePath, buffer)
952+
return { success: true }
953+
} catch (err) {
954+
return { success: false, error: err.message }
955+
}
956+
})
957+
958+
// 在系统资源管理器中打开/定位
959+
ipcMain.handle('reveal-in-explorer', async (event, payload) => {
960+
const targetPath = payload?.path
961+
const isDirectory = !!payload?.isDirectory
962+
if (!targetPath || typeof targetPath !== 'string') {
963+
return { success: false, error: '路径参数无效' }
964+
}
965+
966+
try {
967+
const normalized = path.normalize(targetPath)
968+
if (isDirectory) {
969+
await shell.openPath(normalized)
970+
} else {
971+
shell.showItemInFolder(normalized)
972+
}
973+
return { success: true }
974+
} catch (err) {
975+
return { success: false, error: err.message }
976+
}
977+
})
978+
813979
// 监听目录变化
814980
ipcMain.handle('watch-directory', async (event, payload) => {
815981
try {
@@ -864,9 +1030,17 @@ ipcMain.handle('unwatch-directory', async (event, key) => {
8641030
})
8651031

8661032
// 终端相关 IPC - 主进程直接使用 node-pty
867-
const DEFAULT_SHELL = 'powershell.exe'
868-
const CMD_SHELL = process.env.ComSpec || 'cmd.exe'
869-
const USE_CONPTY = true
1033+
function resolveDefaultUnixShell() {
1034+
const fromEnv = typeof process.env.SHELL === 'string' ? process.env.SHELL.trim() : ''
1035+
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv
1036+
if (fs.existsSync('/bin/zsh')) return '/bin/zsh'
1037+
if (fs.existsSync('/bin/bash')) return '/bin/bash'
1038+
return '/bin/sh'
1039+
}
1040+
1041+
const DEFAULT_SHELL = IS_WINDOWS ? 'powershell.exe' : resolveDefaultUnixShell()
1042+
const CMD_SHELL = IS_WINDOWS ? (process.env.ComSpec || 'cmd.exe') : null
1043+
const USE_CONPTY = IS_WINDOWS
8701044
const PASTE_IMAGE_PREFIX = 'img-'
8711045
const PASTE_IMAGE_EXT = '.png'
8721046
const PASTE_IMAGE_DIR = '.terminal-paste'
@@ -948,6 +1122,7 @@ function ensurePasteDirectory(cwd) {
9481122
}
9491123

9501124
function hideFile(filePath) {
1125+
if (!IS_WINDOWS) return
9511126
try {
9521127
spawnSync('attrib', ['+h', filePath], { windowsHide: true })
9531128
} catch {
@@ -958,18 +1133,33 @@ function hideFile(filePath) {
9581133
function normalizeShell(shell) {
9591134
if (!shell) return DEFAULT_SHELL
9601135
const value = String(shell).toLowerCase()
961-
if (value === 'cmd' || value === 'cmd.exe') return CMD_SHELL
962-
if (value === 'powershell' || value === 'powershell.exe') return DEFAULT_SHELL
963-
return DEFAULT_SHELL
1136+
1137+
if (IS_WINDOWS) {
1138+
if (value === 'cmd' || value === 'cmd.exe') return CMD_SHELL
1139+
if (value === 'powershell' || value === 'powershell.exe') return DEFAULT_SHELL
1140+
return DEFAULT_SHELL
1141+
}
1142+
1143+
if (value === 'zsh') return '/bin/zsh'
1144+
if (value === 'bash') return '/bin/bash'
1145+
if (value === 'sh') return '/bin/sh'
1146+
1147+
return shell
9641148
}
9651149

9661150
function resolveShellArgs(shell) {
9671151
const value = String(shell || '').toLowerCase()
968-
if (value === 'cmd' || value === 'cmd.exe') return ['/D', '/K', 'chcp 65001 >nul']
969-
if (value === 'powershell' || value === 'powershell.exe') {
970-
const init = '$OutputEncoding=[Text.UTF8Encoding]::UTF8; [Console]::InputEncoding=[Text.UTF8Encoding]::UTF8; [Console]::OutputEncoding=[Text.UTF8Encoding]::UTF8; chcp 65001 > $null'
971-
return ['-NoLogo', '-NoExit', '-Command', init]
1152+
if (IS_WINDOWS) {
1153+
if (value === 'cmd' || value === 'cmd.exe') return ['/D', '/K', 'chcp 65001 >nul']
1154+
if (value === 'powershell' || value === 'powershell.exe') {
1155+
const init = '$OutputEncoding=[Text.UTF8Encoding]::UTF8; [Console]::InputEncoding=[Text.UTF8Encoding]::UTF8; [Console]::OutputEncoding=[Text.UTF8Encoding]::UTF8; chcp 65001 > $null'
1156+
return ['-NoLogo', '-NoExit', '-Command', init]
1157+
}
1158+
return []
9721159
}
1160+
1161+
if (value.endsWith('zsh')) return ['-l']
1162+
if (value.endsWith('bash')) return ['--login']
9731163
return []
9741164
}
9751165

@@ -1038,14 +1228,17 @@ function startTerminal(options = {}) {
10381228
useConpty: USE_CONPTY,
10391229
sessionId
10401230
})
1041-
const spawned = pty.spawn(resolvedShell, shellArgs, {
1231+
const ptyOptions = {
10421232
name: 'xterm-256color',
10431233
cols: 80,
10441234
rows: 24,
10451235
cwd: resolvedCwd,
1046-
env: process.env,
1047-
useConpty: USE_CONPTY
1048-
})
1236+
env: process.env
1237+
}
1238+
if (IS_WINDOWS) {
1239+
ptyOptions.useConpty = USE_CONPTY
1240+
}
1241+
const spawned = pty.spawn(resolvedShell, shellArgs, ptyOptions)
10491242
ptySessions.set(sessionId, {
10501243
id: sessionId,
10511244
pty: spawned,

electron/preload.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
const { contextBridge, ipcRenderer, clipboard } = require('electron')
1+
const { contextBridge, ipcRenderer } = require('electron')
22

33
const perfEnabled = process.env.OPENSPEC_PERF_LOG === '1'
44

55
contextBridge.exposeInMainWorld('electronAPI', {
6+
platform: process.platform,
7+
arch: process.arch,
8+
69
// 文件系统操作
710
selectDirectory: () => ipcRenderer.invoke('select-directory'),
811
readDirectory: (dirPath) => ipcRenderer.invoke('read-directory', dirPath),
912
readDirectoryDepth: (dirPath, maxDepth) => ipcRenderer.invoke('read-directory-depth', { dirPath, maxDepth }),
1013
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
14+
saveFile: (filePath, content, options) => ipcRenderer.invoke('save-file', { filePath, content, options }),
1115
watchDirectory: (payload) => ipcRenderer.invoke('watch-directory', payload),
1216
unwatchDirectory: (key) => ipcRenderer.invoke('unwatch-directory', key),
1317

@@ -57,9 +61,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
5761
perfLog: (payload) => ipcRenderer.invoke('perf-log', payload),
5862

5963
// 剪贴板
60-
clipboardReadText: () => clipboard.readText(),
61-
clipboardWriteText: (text) => clipboard.writeText(text || ''),
62-
64+
clipboardReadText: () => ipcRenderer.sendSync('clipboard-read-text'),
65+
clipboardWriteText: (text) => ipcRenderer.send('clipboard-write-text', text || ''),
66+
67+
// 系统资源管理器
68+
revealInExplorer: (payload) => ipcRenderer.invoke('reveal-in-explorer', payload),
69+
6370
// 偏好设置
6471
getPreferences: () => ipcRenderer.invoke('get-preferences'),
6572
setPreferences: (prefs) => ipcRenderer.invoke('set-preferences', prefs),
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# 变更:支持 macOS 打包与基础运行
2+
3+
## 为什么
4+
- 当前构建配置仅输出 Windows 安装包与便携包,macOS 用户无法直接使用。
5+
- 作为开源工具,提供 macOS 产物可以显著降低上手门槛,减少“只能在 Windows 用”的限制。
6+
7+
## 变更内容
8+
- 增加 electron-builder 的 macOS target(dmg/zip,x64/arm64)。
9+
- 引入 GitHub Actions 在 macOS Runner 上自动出包(无签名/无公证)。
10+
- 调整终端默认 shell:Windows 维持 PowerShell/CMD,macOS 使用 zsh/bash,并确保非 Windows 环境不会触发 Windows-only 逻辑崩溃。
11+
- 更新项目约束与使用文档,明确本地构建与 CI 构建的边界。
12+
13+
## 影响
14+
- 受影响规范:terminal(修改默认 shell 与跨平台行为说明)
15+
- 受影响代码:package.json、electron/main.js、electron/preload.js、src/components/TerminalPanel.vue、src/types/index.ts
16+
- 受影响工程:新增 GitHub Actions workflow(macOS 出包)

0 commit comments

Comments
 (0)