独占式文件锁定1

通常被称为**“独占式文件锁定” (Exclusive File Locking)**。
不能完全控制 文件夹里面的文件,新建删除都可以

实现这个功能的难点在于:

  1. 操作系统差异:Windows 的文件锁是强制的(Mandatory),而 Linux/macOS 通常是建议性的(Advisory)。
  2. 句柄限制:操作系统对单个进程能打开的文件句柄数量(File Descriptors)有限制(通常几千个)。如果文件夹里有 node_modules 这种包含数万个文件的目录,直接全锁会导致应用崩溃。
  3. 自身的读写:锁住后,你自己的应用也需要特殊的写入方式(复用句柄),否则也会被拒绝访问。

基于 Electron 环境,我为你设计一套基于 “文件句柄持有 (File Handle Holding)” + “Chokidar 监听” 的完整解决方案。这在 Windows 上效果极佳(完全无法删除/重命名/修改),在 macOS/Linux 上也能阻止大部分操作。


核心原理

  1. 占用 (Lock):主进程使用 fs.open(path, 'r+') 打开文件并不关闭,持有该文件的 fd (文件描述符)。在 Windows 上,只要句柄没关,资源管理器就无法删除或重命名该文件。
  2. 自身写入 (Write):当你的 IDE 需要保存文件时,不重新打开文件,而是直接复用内存中持有的 fd 进行 fs.write
  3. 自身删除/重命名:先 fs.close(fd) 释放锁,执行操作,如果是重命名则再次锁定新路径。

1. 后端核心逻辑 (src/main/lock-manager.ts)

这是整个方案的大脑。建议新建一个类来专门管理锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
// src/main/lock-manager.ts
import fs from 'fs-extra'
import path from 'path'
import chokidar from 'chokidar'

export class LockManager {
// 存储路径到文件描述符(fd)的映射
private locks = new Map<string, number>()
private watcher: chokidar.FSWatcher | null = null
private rootPath: string = ''

/**
* 锁定整个文件夹
*/
async lockDirectory(dirPath: string) {
// 1. 先清理之前的锁
await this.unlockAll()
this.rootPath = dirPath

console.log(`🔒 开始锁定目录: ${dirPath}`)

// 2. 启动监听器,防止外部新建文件“逃逸”
// (如果有人强行塞入新文件,我们立刻锁定它)
this.watcher = chokidar.watch(dirPath, {
ignored: /(^|[\/\\])\../, // 忽略隐藏文件
persistent: true,
ignoreInitial: false, // 初始扫描也触发 add 事件
awaitWriteFinish: true // 等待写入完成后再锁
})

this.watcher.on('add', (filePath) => this.lockFile(filePath))
this.watcher.on('addDir', (dirPath) => this.lockFile(dirPath, true))

// 监听解除锁定(比如被外部强制删除了)
this.watcher.on('unlink', (filePath) => this.releaseLock(filePath))
this.watcher.on('unlinkDir', (dirPath) => this.releaseLock(dirPath))
}

/**
* 释放所有锁 (通常在关闭项目或退出应用时调用)
*/
async unlockAll() {
if (this.watcher) {
await this.watcher.close()
this.watcher = null
}

for (const [filePath, fd] of this.locks.entries()) {
try {
fs.closeSync(fd)
} catch (e) {
// 忽略关闭错误的句柄
}
}
this.locks.clear()
console.log('🔓 已释放所有文件锁')
}

/**
* 核心:锁定单个文件/文件夹
*/
private lockFile(targetPath: string, isDir = false) {
if (this.locks.has(targetPath)) return

try {
// ⚠️ 关键点:使用 'r+' (读写模式) 打开并不关闭
// 对于目录,我们在 Windows 上打开句柄可以防止重命名/移动
// Linux/Mac 对目录 open 的支持有限,通常只支持 'r'
const flags = isDir ? 'r' : 'r+'

const fd = fs.openSync(targetPath, flags)
this.locks.set(targetPath, fd)

// console.log(`Locked: ${path.basename(targetPath)}`)
} catch (error: any) {
// 常见错误:EMFILE (句柄耗尽), EPERM (无权限)
if (error.code === 'EMFILE') {
console.error('❌ 错误:打开的文件太多,达到了系统上限!建议忽略 node_modules')
} else {
console.warn(`无法锁定 ${targetPath}:`, error.message)
}
}
}

private releaseLock(targetPath: string) {
const fd = this.locks.get(targetPath)
if (fd !== undefined) {
try {
fs.closeSync(fd)
} catch (e) {}
this.locks.delete(targetPath)
}
}

// ==========================================
// 提供给 IDE 自身的特权操作 API
// ==========================================

/**
* 特权写入:复用 fd 进行写入
*/
async writeLockedFile(filePath: string, content: string) {
let fd = this.locks.get(filePath)

try {
// 如果还没锁(比如新创建的瞬间),尝试锁一下
if (fd === undefined) {
this.lockFile(filePath)
fd = this.locks.get(filePath)
}

if (fd === undefined) throw new Error('File not locked or accessible')

// Node.js 的 fs.writeFile 支持传入 fd !!!
// 但直接传 fd 可能会追加内容,所以我们需要先清空文件内容
await fs.ftruncate(fd, 0) // 清空文件

// 将内容写入 buffer
const buffer = Buffer.from(content, 'utf-8')

// 使用 writeSync 或 write 写入
// position: 0 (从头开始写)
fs.writeSync(fd, buffer, 0, buffer.length, 0)

console.log(`✅ 特权写入成功: ${filePath}`)
return true
} catch (error) {
console.error('写入失败:', error)
throw error
}
}

/**
* 特权重命名
*/
async renameLockedEntry(oldPath: string, newName: string) {
const dir = path.dirname(oldPath)
const newPath = path.join(dir, newName)

// 1. 必须先释放锁,否则自己也无法重命名 (Windows特性)
this.releaseLock(oldPath)

try {
await fs.rename(oldPath, newPath)
// 2. 这里的锁会由 Chokidar 的 'add' 事件自动加上,或者手动加
// this.lockFile(newPath)
return newPath
} catch (error) {
// 失败了尝试重新锁回去
this.lockFile(oldPath)
throw error
}
}

/**
* 特权删除
*/
async deleteLockedEntry(targetPath: string) {
// 1. 释放锁
this.releaseLock(targetPath)

// 如果是目录,可能需要递归释放子文件锁
// 简单起见,这里假设外部调用逻辑会处理,或者依赖 Chokidar 的 unlink
// 为了稳健,我们遍历一下 map 释放所有子路径
for (const key of this.locks.keys()) {
if (key.startsWith(targetPath)) {
this.releaseLock(key)
}
}

try {
await fs.remove(targetPath)
} catch (error) {
// 恢复锁很难,因为可能删了一半
throw error
}
}

/**
* 特权创建文件
*/
async createLockedFile(folderPath: string, name: string) {
const fullPath = path.join(folderPath, name)
await fs.ensureFile(fullPath)
// 创建完立刻锁
this.lockFile(fullPath)
}
}

// 导出单例
export const lockManager = new LockManager()

2. 注册 IPC 接口 (src/main/ipc/lock-ipc.ts)

将这些特权操作暴露给前端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// src/main/ipc/lock-ipc.ts
import { ipcMain } from 'electron'
import { lockManager } from '../lock-manager'

export function setupLockIPC() {

// 1. 打开并锁定工程
ipcMain.handle('lock-open-project', async (_, dirPath: string) => {
await lockManager.lockDirectory(dirPath)
return true
})

// 2. 关闭工程(释放锁)
ipcMain.handle('lock-close-project', async () => {
await lockManager.unlockAll()
})

// 3. 特权写入 (替代 fs.writeFile)
ipcMain.handle('lock-write-file', async (_, { filePath, content }) => {
return await lockManager.writeLockedFile(filePath, content)
})

// 4. 特权删除
ipcMain.handle('lock-delete', async (_, filePath: string) => {
return await lockManager.deleteLockedEntry(filePath)
})

// 5. 特权重命名
ipcMain.handle('lock-rename', async (_, { oldPath, newName }) => {
return await lockManager.renameLockedEntry(oldPath, newName)
})
}

并在 src/main/index.ts 中调用:

1
2
3
import { setupLockIPC } from './ipc/lock-ipc'
// ...
setupLockIPC()

3. Preload 暴露 (src/preload/index.ts)

1
2
3
4
5
6
7
8
9
const lockAPI = {
openProject: (path: string) => ipcRenderer.invoke('lock-open-project', path),
closeProject: () => ipcRenderer.invoke('lock-close-project'),
writeFile: (path: string, content: string) => ipcRenderer.invoke('lock-write-file', { filePath: path, content }),
delete: (path: string) => ipcRenderer.invoke('lock-delete', path),
rename: (oldPath: string, newName: string) => ipcRenderer.invoke('lock-rename', { oldPath, newName })
}

// expose ...

4. 前端使用示例 (Editor.vue)

在前端,你不再使用普通的 fsAPI.saveFile,而是必须使用 lockAPI.writeFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<script setup lang="ts">
// 打开项目时
const openProject = async (path) => {
// 这会触发后端递归锁定
await window.lockAPI.openProject(path)
console.log('项目已打开且已独占锁定')
}

// 保存文件时
const saveDocument = async (currentFile, content) => {
try {
// 使用特权通道写入
await window.lockAPI.writeFile(currentFile.path, content)
console.log('保存成功')
} catch (e) {
console.error('保存失败', e)
alert('无法保存,可能锁失效或权限不足')
}
}

// 退出应用前
window.onbeforeunload = () => {
// 最好主动释放,虽然 Electron 进程结束 OS 会自动回收句柄,但主动清理更安全
window.lockAPI.closeProject()
}
</script>

⚠️ 关键限制与注意事项 (必读)

  1. 文件句柄上限 (EMFILE Error)

    • 现象:如果用户打开了一个包含 node_modules 的文件夹,里面可能有 30,000+ 个文件。操作系统通常默认限制单进程打开 1024 或 4096 个文件。尝试全锁会直接导致你的应用崩溃或报错。
    • 解决方案
      • 忽略策略:在 LockManagerchokidar 配置和递归逻辑中,必须忽略 node_modules, .git, dist 等构建目录。只锁定核心代码文件。
      • 优雅降级:在 lockFile 方法中捕获 EMFILE 错误,如果句柄满了,停止锁定后续文件,并通知前端“部分文件未锁定”。
  2. Windows vs POSIX

    • Windows:此方案效果极佳。fs.open 默认会阻止删除和重命名。此时如果你尝试用资源管理器删文件,会提示“文件在 Electron 中已打开”。
    • macOS / Linux:此方案效果有限。Unix 哲学允许删除正在被进程使用的文件(只删除引用,句柄依然有效)。要实现完全禁止修改,通常需要:
      • 使用 fs-ext 原生模块调用 flock(fd, LOCK_EX)(排他锁)。但这增加了编译成本。
      • 或者简单地:在 Unix 上接受“软锁定”,即你的应用能正常跑,但用户如果在终端 rm -rf,你拦不住。
  3. 性能开销

    • 保持数千个文件句柄打开会消耗内存(虽然不多)。
    • chokidar 监听大量文件会消耗 CPU。

总结

这个方案通过 “不释放句柄 (Leaking FDs intentionally)” 的方式实现了应用层面的独占。对于开发一个需要“独占模式”的 IDE 来说,这是最标准且纯 JS (Node) 能做到的极限。如果需要更强力的锁(如拦截系统级写入),则需要编写 C++ 驱动层,那将远超 Electron 的范畴。

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器