FrameEver dev pane: grouped buttons for pnpm fe (run, check, export, deps, release notes, package)

<img src="apps/desktop-gpui/icons/128x128@2x.png" alt="FrameEver" width="112" />
一刻,凝成永恒
纯本地的 Windows 录屏与剪辑工具:不限时长、不用账号、不上传任何内容、完全免费。
录制
编辑
导出与管理
其他
frameever,可供脚本、自动化规则和 AI 智能体调用(说明)从 Releases 下载 FrameEver_<版本>_x64-setup.exe 并运行。支持 Windows 10 / 11(64 位)。安装后应用会自动检查更新。
FrameEver 只在以下情况联网,且不会发送你录制的任何内容:
| Cap 原版 | FrameEver | |
|---|---|---|
| 录制时长与数量 | 免费版有限制 | 无限制 |
| 账号 | 需要注册 | 不需要 |
| 分享与存储 | 上传到云端 | 只保存在本机 |
| AI 字幕 | 云端服务 | 本地模型,离线 |
| 导出编码 | H.264 | H.264、HEVC、AV1 |
| 界面语言 | 英文 | 简体中文、英文 |
| 平台 | macOS、Windows | Windows(macOS 版在 Windows 1.0 之后) |
pnpm fe setup # 准备开发环境(首次)
pnpm fe dev # 用独立的测试数据运行应用
pnpm fe test # 运行测试
pnpm fe package # 打包安装程序到 dist/
完整说明见 BUILD.md,贡献前请阅读 CONTRIBUTING.md。技术栈:Rust、GPUI、FFmpeg、Whisper / Parakeet。
FrameEver 基于 Cap 改造,遵循 AGPL-3.0 协议:保留原始版权声明、公开全部源代码。部分模块(frameever-camera*、scap-*)为 MIT 协议,见各模块的 LICENSE。FrameEver 是独立项目,与 Cap 官方无关,不使用 Cap 的名称和标志。
感谢 Cap 团队和所有贡献者。界面字体使用 Inter(SIL OFL 1.1)和华为 HarmonyOS Sans。
如果 FrameEver 对你有帮助,欢迎点个 ⭐ Star
<img src=".github/images/wechat.png" width="180" alt="微信赞赏码" /> <sub>微信 WeChat</sub>
hooks/register.tsx 312 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { AppLanguage, JobState } from '../types'
5
6const PANE = 'frameever-dev'
7const TITLE = 'FrameEver 开发'
8const MAX_LINES = 400
9
10const jobs = atom({ plugin: 'frameever-dev', key: 'jobs' } as const, {})
11const selected = atom({ plugin: 'frameever-dev', key: 'selected' } as const, null)
12const language = atom({ plugin: 'frameever-dev', key: 'language' } as const, 'zh-CN')
13
14type Job = {
15 id: string
16 label: string
17 args: (lang: AppLanguage, version: string) => string[]
18 needsVersion?: boolean
19 launchesApp?: boolean
20 primary?: boolean
21}
22
23type Group = { title: string; jobs: Job[] }
24
25const fixed = (...args: string[]) => () => args
26
27async function nextVersion($: EngineInterface) {
28 const changelog = await $.fs.read(`${root}\\CHANGELOG.md`).catch(() => '')
29 return changelog.match(/^## \[(\d+\.\d+\.\d+[^\]]*)\]/m)?.[1] ?? '0.0.0'
30}
31
32const GROUPS: Group[] = [
33 {
34 title: '运行',
35 jobs: [
36 { id: 'dev', label: '运行应用', args: lang => ['dev', '--lang', lang], launchesApp: true, primary: true },
37 { id: 'dev-watch', label: '热重载开发', args: lang => ['dev', '--watch', '--lang', lang], launchesApp: true },
38 { id: 'logs', label: '查看日志', args: fixed('logs', '--lines', '300') },
39 ],
40 },
41 {
42 title: '检查',
43 jobs: [
44 { id: 'check', label: '编译检查', args: fixed('check') },
45 { id: 'test', label: '测试', args: fixed('test') },
46 { id: 'test-all', label: '全部测试', args: fixed('test', '--all') },
47 { id: 'lint', label: '代码检查', args: fixed('lint') },
48 { id: 'fmt', label: '格式化', args: fixed('fmt') },
49 { id: 'doctor', label: '环境检查', args: fixed('doctor') },
50 ],
51 },
52 {
53 title: '导出测试录像',
54 jobs: [
55 { id: 'export-h264', label: 'H.264', args: fixed('export', 'h264') },
56 { id: 'export-hevc', label: 'HEVC', args: fixed('export', 'hevc') },
57 { id: 'export-av1', label: 'AV1', args: fixed('export', 'av1') },
58 ],
59 },
60 {
61 title: '依赖与发布',
62 jobs: [
63 { id: 'deps', label: '依赖检查', args: fixed('deps') },
64 { id: 'deps-apply', label: '升级依赖', args: fixed('deps', '--apply') },
65 { id: 'notes', label: '预览更新记录', args: (_, version) => ['notes', version], needsVersion: true },
66 { id: 'package', label: '打包安装程序', args: fixed('package') },
67 { id: 'setup', label: '准备环境', args: fixed('setup') },
68 ],
69 },
70]
71
72const ALL_JOBS = GROUPS.flatMap(group => group.jobs)
73
74const FOLDERS = [
75 { key: 'open-testdata', label: '测试数据', path: 'apps\\desktop-gpui\\target\\testdata' },
76 { key: 'open-exports', label: '导出视频', path: 'apps\\desktop-gpui\\target\\testdata\\exports' },
77 { key: 'open-logs', label: '日志', path: 'apps\\desktop-gpui\\target\\testdata\\logs' },
78 { key: 'open-dist', label: '安装包', path: 'dist' },
79]
80
81const LANGUAGES: { value: AppLanguage; label: string }[] = [
82 { value: 'zh-CN', label: '中文' },
83 { value: 'en', label: 'English' },
84]
85
86const DEV_PID_FILE = 'apps\\desktop-gpui\\target\\testdata\\fe-dev.pid'
87
88const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[A-Za-z]`, 'g')
89
90const STATUS_LABEL = { running: '运行中', passed: '通过', failed: '失败', stopped: '已停止' }
91
92let root = '.'
93const running = new Map<string, { stop: () => void; isStopped: boolean }>()
94
95async function append($: EngineInterface, id: string, lines: string[]) {
96 if (lines.length === 0) {
97 return
98 }
99 await update($, jobs, all => {
100 const job = all[id]
101 if (!job) {
102 return all
103 }
104 return { ...all, [id]: { ...job, lines: [...job.lines, ...lines].slice(-MAX_LINES) } }
105 })
106}
107
108async function stopApp($: EngineInterface) {
109 const pid = await $.fs.read(`${root}\\${DEV_PID_FILE}`).catch(() => '')
110 if (/^\d+$/.test(pid.trim())) {
111 await $.process.run(['taskkill', '/F', '/T', '/PID', pid.trim()]).catch(() => undefined)
112 }
113}
114
115async function runJob($: EngineInterface, job: Job) {
116 if (running.has(job.id)) {
117 await update($, selected, () => job.id)
118 return
119 }
120 const version = job.needsVersion ? await nextVersion($) : ''
121 const args = job.args(await read($, language), version)
122 const startedAt = await $.clock.now()
123 await update($, jobs, all => ({
124 ...all,
125 [job.id]: {
126 status: 'running' as const,
127 startedAt,
128 endedAt: null,
129 lines: [`$ pnpm fe ${args.join(' ')}`],
130 },
131 }))
132 await update($, selected, () => job.id)
133
134 const handle = { stop: () => {}, isStopped: false }
135 running.set(job.id, handle)
136 let status: JobState['status'] = 'passed'
137 try {
138 const stream = $.process.spawn({
139 argv: ['node', 'scripts/fe.mjs', ...args],
140 cwd: root,
141 env: { FORCE_COLOR: '0' },
142 })
143 handle.stop = () => {
144 handle.isStopped = true
145 void stream.return(undefined as never)
146 }
147 let partial = ''
148 let next = await stream.next()
149 while (!next.done) {
150 const text = (partial + next.value.text).replace(ANSI, '')
151 const pieces = text.split(/\r?\n/)
152 partial = pieces.pop() ?? ''
153 await append($, job.id, pieces)
154 next = await stream.next()
155 }
156 await append($, job.id, partial ? [partial] : [])
157 if (handle.isStopped) {
158 status = 'stopped'
159 } else if (next.value?.code !== 0) {
160 status = 'failed'
161 }
162 } catch (error) {
163 status = handle.isStopped ? 'stopped' : 'failed'
164 if (!handle.isStopped) {
165 await append($, job.id, [String(error instanceof Error ? error.message : error)])
166 }
167 }
168 if (job.launchesApp) {
169 await stopApp($)
170 }
171 running.delete(job.id)
172 const endedAt = await $.clock.now()
173 await update($, jobs, all => {
174 const state = all[job.id]
175 return state ? { ...all, [job.id]: { ...state, status, endedAt } } : all
176 })
177 if (status !== 'stopped') {
178 $.ui.toast(`${job.label}:${status === 'passed' ? '完成' : '失败'}`)
179 }
180}
181
182export const register: Register = on => {
183 on('session.start', async ($, e, next) => {
184 const started = await next(e)
185 root = e.cwd || '.'
186 await $.command.register({
187 name: 'fe-dev',
188 description: 'Open the FrameEver dev pane (pnpm fe: run, check, test, export, deps, package)',
189 })
190 void $.ui.open({ id: PANE, title: TITLE })
191 return started
192 })
193
194 on('command.run', { command: 'fe-dev' }, async $ => {
195 await $.ui.open({ id: PANE, title: TITLE })
196 return { text: 'FrameEver 开发面板已打开。' }
197 })
198
199 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
200 const { Box, Button, Text } = $.ui.resolve(e)
201 const all = await read($, jobs)
202 const shown = await read($, selected)
203 const lang = await read($, language)
204 const now = await $.clock.now()
205 const seconds = (job: JobState) => Math.round(((job.endedAt ?? now) - job.startedAt) / 1000)
206 const color = (job: JobState) =>
207 job.status === 'passed' ? 'success' : job.status === 'failed' ? 'error' : 'subtle'
208 const output = shown ? all[shown] : undefined
209 const shownJob = ALL_JOBS.find(job => job.id === shown)
210 const started = ALL_JOBS.filter(job => all[job.id])
211 const room = Math.max(8, (e.viewport?.rows ?? 40) - 22)
212
213 const row = (title: string, children: RenderChildren) => (
214 <Box key={`group-${title}`} flexDirection="column">
215 <Text bold>{title}</Text>
216 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
217 {children}
218 </Box>
219 </Box>
220 )
221
222 return (
223 <Box flexDirection="column" gap={1}>
224 {GROUPS.map(group =>
225 row(group.title, [
226 ...group.jobs.map(job => (
227 <Button
228 key={`run-${job.id}`}
229 label={all[job.id]?.status === 'running' ? `${job.label} …` : job.label}
230 variant={job.primary ? 'primary' : undefined}
231 onPress={() => {
232 void runJob($, job)
233 }}
234 />
235 )),
236 ...(group.title === '运行'
237 ? LANGUAGES.map(option => (
238 <Button
239 key={`lang-${option.value}`}
240 label={option.label}
241 plain
242 dimColor={lang !== option.value}
243 onPress={() => update($, language, () => option.value)}
244 />
245 ))
246 : []),
247 ]),
248 )}
249 {row(
250 '打开文件夹',
251 FOLDERS.map(folder => (
252 <Button
253 key={folder.key}
254 label={folder.label}
255 dimColor
256 onPress={async () => {
257 await $.process.run(['explorer', `${root}\\${folder.path}`]).catch(() => undefined)
258 }}
259 />
260 )),
261 )}
262 {started.length > 0 &&
263 row(
264 '任务',
265 started.map(job => {
266 const state = all[job.id]!
267 return (
268 <Button
269 key={`show-${job.id}`}
270 label={`${job.label} ${STATUS_LABEL[state.status]} ${seconds(state)}s`}
271 plain
272 dimColor={shown !== job.id}
273 onPress={() => update($, selected, () => job.id)}
274 />
275 )
276 }),
277 )}
278 {output && shownJob ? (
279 <Box flexDirection="column">
280 <Box flexDirection="row" columnGap={1}>
281 <Text>
282 {shownJob.label} · <Text color={color(output)}>{STATUS_LABEL[output.status]}</Text> ·{' '}
283 {seconds(output)}s
284 </Text>
285 {output.status === 'running' && (
286 <Button
287 key="stop-shown"
288 label="停止"
289 dimColor
290 onPress={() => {
291 running.get(shownJob.id)?.stop()
292 }}
293 />
294 )}
295 </Box>
296 {output.lines.slice(-room).map((line, index) => (
297 <Text key={`line-${index}`} dimColor>
298 {line || ' '}
299 </Text>
300 ))}
301 </Box>
302 ) : (
303 <Text dimColor>
304 点按钮开始,所有按钮都调用 pnpm fe。“运行应用”用独立的测试数据。发版在终端运行 pnpm fe release
305 <版本>,会先跑全部测试并请你确认。
306 </Text>
307 )}
308 </Box>
309 )
310 })
311}
312types/index.d.ts 21 lines1export type JobStatus = 'running' | 'passed' | 'failed' | 'stopped'
2
3export type JobState = {
4 status: JobStatus
5 startedAt: number
6 endedAt: number | null
7 lines: string[]
8}
9
10export type AppLanguage = 'zh-CN' | 'en'
11
12declare module 'claude-code' {
13 interface PluginState {
14 'frameever-dev': {
15 jobs: Record<string, JobState>
16 selected: string | null
17 language: AppLanguage
18 }
19 }
20}
21