Skip to content

Commit 51c8f99

Browse files
authored
fix(mpe): allow clipboard and load .mpe.json sidecar (#83)
1 parent 64d5020 commit 51c8f99

5 files changed

Lines changed: 726 additions & 37 deletions

File tree

docs/extension/models/README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,14 @@ MaaFramework 的 pipeline 开发者。用户通过 VSCode 编辑 JSON/JSONC 格
129129
- 可从文件树、编辑器正文、编辑器标题栏或命令面板打开
130130
- 同一文件复用已有面板,不同文件保持独立的加载、同步和保存状态
131131
- 初次打开和“从 MSE 同步”读取当前 VS Code `TextDocument` 内容,包含尚未落盘的修改
132+
- 若同目录存在分离配置 `.{文件名}.mpe.json`,打开和同步时会一并读取并合并到画布;保存时把 Pipeline 与 sidecar 放进同一次 `WorkspaceEdit` 拆回,避免把 `$__mpe_*` 写进 Pipeline
133+
- sidecar 是画布派生的布局缓存,保存以 MPE 画布为准,不与 Pipeline 对等做冲突确认;删除后再次保存会按分离模式重建
134+
- 只有 sidecar 文件不存在才按集成模式处理。文件存在但打不开、JSON 损坏或字段类型错误时拒绝加载并报错
135+
- 未成功加载完成前禁止保存(含加载进行中和加载失败),避免空画布覆盖 Pipeline;加载成功后恢复保存
132136
- MPE 保存通过 `WorkspaceEdit` 写回原文档,保留 VS Code 的 dirty、undo/redo 和正常保存语义,不直接覆盖磁盘
133-
- MPE 加载后若源文档被外部编辑,保存时会提示先从 MSE 同步;用户也可以确认使用 MPE 内容强制覆盖
137+
- MPE 加载后若 Pipeline 源文档被外部编辑,保存时会提示先从 MSE 同步;用户也可以确认使用 MPE 内容强制覆盖
134138
- MPE iframe 使用 v1.3.0 协议;外部链接由宿主校验后交给 VS Code 打开,文档冲突由 MPE 提供同步/强制覆盖选择
139+
- 嵌入 iframe 会向 MPE 下放剪贴板读写权限,使复制/粘贴走浏览器 Clipboard API 而不是被 Webview Permissions-Policy 拦截
135140
- MPE 地址可通过 `maa.pipelineEditorUrl` 配置,生产地址要求 HTTPS,本机 localhost 开发地址允许 HTTP
136141

137142
该面板是外部编辑器集成,不改变普通 Pipeline 编辑器、命令和菜单的既有行为。

docs/extension/tech/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ src/
2929
├── shortcut.ts # ShortcutService — 全局快捷键目标租约和跨窗口请求转发
3030
├── native.ts # NativeService — MaaFramework 二进制管理
3131
├── server.ts # ServerService — RPC 连接管理
32-
├── mpe.ts # MPE iframe 面板、握手、同步与保存桥接
32+
├── mpe.ts # MPE iframe 面板、握手、版本稳定快照、损坏 sidecar 拒绝加载、未成功加载禁止保存、sidecar 与 Pipeline 同一次保存
33+
├── mpeProtocol.ts # mpe-embed 协议校验、JSONC 写回、`.mpe.json` 合并/拆分、sidecar 缺失/损坏/字段类型判定、加载授权
3334
├── root.ts # RootService — 资源根目录扫描
3435
├── interface.ts # InterfaceService — 接口包管理
3536
├── launch.ts # LaunchService — 任务启动编排

pkgs/extension/src/service/mpe.ts

Lines changed: 164 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,48 @@ import { isMaaAssistantArknights } from '../utils/fs'
88
import { logger } from '../utils/logger'
99
import { BaseService } from './context'
1010
import {
11+
type MpeConfig,
12+
type MpeLoadAuth,
1113
type MpeProtocolMessage,
14+
beginMpeLoad,
15+
finishMpeLoad,
1216
hasDocumentVersionConflict,
1317
isCompatibleMpeMessage,
18+
isCurrentDocumentSnapshot,
1419
isMpeReadyForRequest,
20+
isMpeSaveAllowed,
21+
isSeparatedMpeSidecar,
22+
isSidecarNotFound,
23+
mergePipelineAndConfig,
1524
mpeProtocol,
1625
mpeProtocolVersion,
26+
mpeSidecarPath,
1727
normalizeExternalUrl,
28+
parseMpeConfig,
1829
parsePipeline,
30+
splitPipelineAndConfig,
31+
stringifyMpeConfig,
1932
updatePipelineText
2033
} from './mpeProtocol'
2134
import { interfaceService } from './registry'
2235

2336
const repositoryUrl = 'https://github.com/neko-para/maa-support-extension'
2437
const defaultUrl = 'https://mpe.codax.site/stable/'
38+
const snapshotRetries = 3
2539

2640
function asRecord(value: unknown): Record<string, unknown> | undefined {
2741
return value && typeof value === 'object' && !Array.isArray(value)
2842
? (value as Record<string, unknown>)
2943
: undefined
3044
}
3145

32-
function errorCode(error: unknown) {
33-
if (!error || typeof error !== 'object' || !('code' in error)) return 'save_failed'
34-
return typeof error.code === 'string' ? error.code : 'save_failed'
46+
function errorCode(error: unknown, fallback = 'save_failed') {
47+
if (!error || typeof error !== 'object' || !('code' in error)) return fallback
48+
return typeof error.code === 'string' ? error.code : fallback
49+
}
50+
51+
function documentRange(document: vscode.TextDocument) {
52+
return new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length))
3553
}
3654

3755
function escapeHtml(value: string) {
@@ -107,12 +125,15 @@ export class MpeService extends BaseService {
107125
class MpePanel implements vscode.Disposable {
108126
readonly panel: vscode.WebviewPanel
109127
private ready = false
128+
private loadAuth: MpeLoadAuth = 'idle'
110129
private disposed = false
111130
private queue: MpeProtocolMessage[] = []
112131
private pendingSave?: { requestId: string; documentVersion: number; force: boolean }
113132
private loadedDocumentVersion?: number
133+
private separatedConfigUri?: vscode.Uri
114134
private saveTimeout?: ReturnType<typeof setTimeout>
115135
private requestCounter = 0
136+
private loadSeq = 0
116137
private readonly disposables: vscode.Disposable[] = []
117138
onDispose = () => {}
118139

@@ -189,7 +210,7 @@ class MpePanel implements vscode.Disposable {
189210
host: { id: 'mse', name: 'MSE', repositoryUrl }
190211
}
191212
}
192-
this.panel.webview.html = `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${csp}"><style>html,body{box-sizing:border-box;margin:0;padding:0;width:100%;height:100%;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style></head><body><iframe id="mpe" src="${frameUrl}" title="MaaPipelineEditor"></iframe><script nonce="${nonce}">
213+
this.panel.webview.html = `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${csp}"><style>html,body{box-sizing:border-box;margin:0;padding:0;width:100%;height:100%;overflow:hidden}iframe{display:block;width:100%;height:100%;border:0}</style></head><body><iframe id="mpe" src="${frameUrl}" title="MaaPipelineEditor" allow="clipboard-read; clipboard-write; clipboard-sanitized-write"></iframe><script nonce="${nonce}">
193214
const api=acquireVsCodeApi(), frame=document.getElementById('mpe');
194215
window.addEventListener('message',e=>{if(e.source===frame.contentWindow&&e.origin==='${origin}'&&e.data?.protocol==='mpe-embed')api.postMessage(e.data);else if(e.source!==frame.contentWindow)frame.contentWindow?.postMessage(e.data,'${origin}')});
195216
frame.addEventListener('load',()=>api.postMessage({builtin:'mpe-host-ready'}));
@@ -201,7 +222,9 @@ frame.addEventListener('load',()=>api.postMessage({builtin:'mpe-host-ready'}));
201222
private receive(value: unknown) {
202223
if (asRecord(value)?.builtin === 'mpe-host-ready') {
203224
this.ready = false
225+
this.loadAuth = beginMpeLoad()
204226
this.pendingSave = undefined
227+
this.loadSeq += 1
205228
if (this.saveTimeout) clearTimeout(this.saveTimeout)
206229
if (this.initMessage) this.panel.webview.postMessage(this.initMessage)
207230
return
@@ -217,10 +240,10 @@ frame.addEventListener('load',()=>api.postMessage({builtin:'mpe-host-ready'}));
217240
return
218241
this.ready = true
219242
this.flush()
220-
this.load(`mse-load-${Date.now()}-${++this.requestCounter}`)
243+
void this.load(`mse-load-${Date.now()}-${++this.requestCounter}`)
221244
break
222245
case 'mpe:reloadRequest':
223-
this.load(message.requestId)
246+
void this.load(message.requestId)
224247
break
225248
case 'mpe:saveRequest':
226249
this.requestSave(message)
@@ -234,28 +257,99 @@ frame.addEventListener('load',()=>api.postMessage({builtin:'mpe-host-ready'}));
234257
}
235258
}
236259

237-
private load(requestId?: string) {
260+
private async load(requestId?: string) {
261+
const seq = ++this.loadSeq
262+
this.loadAuth = beginMpeLoad()
238263
try {
239-
const data = parsePipeline(this.document.getText())
240-
this.loadedDocumentVersion = this.document.version
264+
const snapshot = await this.pipelineSnapshot()
265+
if (this.disposed || seq !== this.loadSeq) {
266+
return
267+
}
268+
this.loadedDocumentVersion = snapshot.version
269+
this.loadAuth = finishMpeLoad(true)
241270
this.send({
242271
protocol: mpeProtocol,
243272
version: mpeProtocolVersion,
244273
type: 'mpe:loadPipeline',
245274
requestId,
246-
payload: { fileName: path.basename(this.document.fileName), data }
275+
payload: { fileName: path.basename(this.document.fileName), data: snapshot.data }
247276
})
248277
} catch (error) {
278+
if (this.disposed || seq !== this.loadSeq) {
279+
return
280+
}
281+
this.loadAuth = finishMpeLoad(false)
249282
this.send({
250283
protocol: mpeProtocol,
251284
version: mpeProtocolVersion,
252285
type: 'mpe:error',
253286
requestId,
254-
payload: { code: 'invalid_pipeline', message: String(error) }
287+
payload: { code: errorCode(error, 'invalid_pipeline'), message: String(error) }
255288
})
256289
}
257290
}
258291

292+
private sidecarUri() {
293+
return vscode.Uri.file(mpeSidecarPath(this.document.uri.fsPath))
294+
}
295+
296+
private async readSidecar(uri: vscode.Uri) {
297+
try {
298+
await vscode.workspace.fs.stat(uri)
299+
} catch (error) {
300+
if (isSidecarNotFound(error)) {
301+
return { status: 'missing' as const }
302+
}
303+
logger.warn(`Failed to stat MPE config ${path.basename(uri.fsPath)}: ${String(error)}`)
304+
return { status: 'invalid' as const, error }
305+
}
306+
try {
307+
return {
308+
status: 'ok' as const,
309+
config: parseMpeConfig((await vscode.workspace.openTextDocument(uri)).getText())
310+
}
311+
} catch (error) {
312+
if (isSidecarNotFound(error)) {
313+
return { status: 'missing' as const }
314+
}
315+
logger.warn(`Failed to read MPE config ${path.basename(uri.fsPath)}: ${String(error)}`)
316+
return { status: 'invalid' as const, error }
317+
}
318+
}
319+
320+
private async pipelineSnapshot() {
321+
const sidecarUri = this.sidecarUri()
322+
for (let attempt = 0; attempt < snapshotRetries; attempt++) {
323+
const version = this.document.version
324+
const pipeline = parsePipeline(this.document.getText())
325+
const sidecar = await this.readSidecar(sidecarUri)
326+
if (!isCurrentDocumentSnapshot(version, this.document.version)) {
327+
continue
328+
}
329+
if (sidecar.status === 'invalid') {
330+
throw Object.assign(new Error(`MPE config is invalid: ${String(sidecar.error)}`), {
331+
code: 'invalid_config'
332+
})
333+
}
334+
if (sidecar.status === 'missing') {
335+
this.separatedConfigUri = undefined
336+
return { data: pipeline, version }
337+
}
338+
this.separatedConfigUri = sidecarUri
339+
// mpe:loadPipeline only accepts a combined pipeline object, so merge the sidecar here.
340+
return {
341+
data: mergePipelineAndConfig(
342+
pipeline,
343+
sidecar.config,
344+
path.basename(this.document.fileName).replace(/\.(json|jsonc)$/i, ''),
345+
Object.keys(pipeline)
346+
),
347+
version
348+
}
349+
}
350+
throw new Error('Pipeline changed while loading MPE snapshot')
351+
}
352+
259353
private requestSave(message: MpeProtocolMessage) {
260354
const requestId = message.requestId
261355
if (!requestId || this.pendingSave) return
@@ -294,45 +388,76 @@ frame.addEventListener('load',()=>api.postMessage({builtin:'mpe-host-ready'}));
294388
if (!pending || message.requestId !== pending.requestId) return
295389
this.pendingSave = undefined
296390
if (this.saveTimeout) clearTimeout(this.saveTimeout)
297-
try {
391+
const rejectIfChanged = () => {
298392
if (
299-
!pending.force &&
300-
hasDocumentVersionConflict(
393+
pending.force ||
394+
!hasDocumentVersionConflict(
301395
this.loadedDocumentVersion,
302396
pending.documentVersion,
303397
this.document.version
304398
)
305399
) {
400+
return false
401+
}
402+
this.send({
403+
protocol: mpeProtocol,
404+
version: mpeProtocolVersion,
405+
type: 'mpe:saveResult',
406+
requestId: pending.requestId,
407+
payload: {
408+
success: false,
409+
code: 'document_changed',
410+
message: 'The host document has changed',
411+
canForce: true
412+
}
413+
})
414+
return true
415+
}
416+
try {
417+
if (!isMpeSaveAllowed(this.loadAuth)) {
306418
this.send({
307419
protocol: mpeProtocol,
308420
version: mpeProtocolVersion,
309421
type: 'mpe:saveResult',
310422
requestId: pending.requestId,
311423
payload: {
312424
success: false,
313-
code: 'document_changed',
314-
message: 'The host document has changed',
315-
canForce: true
425+
code: 'save_blocked',
426+
message: '请先加载成功再保存'
316427
}
317428
})
318429
return
319430
}
431+
if (rejectIfChanged()) {
432+
return
433+
}
320434
const data = asRecord(asRecord(message.payload)?.data)
321435
if (!data)
322436
throw Object.assign(new Error('MPE returned invalid Pipeline data'), {
323437
code: 'invalid_pipeline'
324438
})
439+
const sidecarUri = this.separatedConfigUri ?? this.sidecarUri()
440+
const sidecar = await this.readSidecar(sidecarUri)
441+
const separated = isSeparatedMpeSidecar(!!this.separatedConfigUri, sidecar)
442+
if (rejectIfChanged()) {
443+
return
444+
}
445+
const next = separated ? splitPipelineAndConfig(data) : undefined
325446
const original = this.document.getText()
326-
const edits = updatePipelineText(original, parsePipeline(original), data)
327-
const edit = new vscode.WorkspaceEdit()
328-
edit.replace(
329-
this.document.uri,
330-
new vscode.Range(
331-
this.document.positionAt(0),
332-
this.document.positionAt(this.document.getText().length)
333-
),
334-
edits
447+
const pipelineText = updatePipelineText(
448+
original,
449+
parsePipeline(original),
450+
next?.pipeline ?? data
335451
)
452+
if (rejectIfChanged()) {
453+
return
454+
}
455+
const edit = new vscode.WorkspaceEdit()
456+
if (next) {
457+
this.appendSidecarEdit(edit, sidecarUri, next.config)
458+
this.separatedConfigUri = sidecarUri
459+
}
460+
edit.replace(this.document.uri, documentRange(this.document), pipelineText)
336461
if (!(await vscode.workspace.applyEdit(edit)))
337462
throw new Error('VS Code rejected the document edit')
338463
this.loadedDocumentVersion = this.document.version
@@ -358,6 +483,19 @@ frame.addEventListener('load',()=>api.postMessage({builtin:'mpe-host-ready'}));
358483
}
359484
}
360485

486+
private appendSidecarEdit(edit: vscode.WorkspaceEdit, uri: vscode.Uri, config: MpeConfig) {
487+
const text = stringifyMpeConfig(config)
488+
const open = vscode.workspace.textDocuments.find(doc => doc.uri.toString() === uri.toString())
489+
if (open) {
490+
edit.replace(uri, documentRange(open), text)
491+
return
492+
}
493+
edit.createFile(uri, {
494+
overwrite: true,
495+
contents: new TextEncoder().encode(text)
496+
})
497+
}
498+
361499
private async openExternal(value: unknown) {
362500
const url = normalizeExternalUrl(value)
363501
if (!url) {

0 commit comments

Comments
 (0)