@@ -8,30 +8,48 @@ import { isMaaAssistantArknights } from '../utils/fs'
88import { logger } from '../utils/logger'
99import { BaseService } from './context'
1010import {
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'
2134import { interfaceService } from './registry'
2235
2336const repositoryUrl = 'https://github.com/neko-para/maa-support-extension'
2437const defaultUrl = 'https://mpe.codax.site/stable/'
38+ const snapshotRetries = 3
2539
2640function 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
3755function escapeHtml ( value : string ) {
@@ -107,12 +125,15 @@ export class MpeService extends BaseService {
107125class 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 } ">
193214const api=acquireVsCodeApi(), frame=document.getElementById('mpe');
194215window.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 } ')});
195216frame.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 ( / \. ( j s o n | j s o n c ) $ / 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