dsh-markdown-typora 0.3.5 → 0.3.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -14
- package/core.json +5 -5
- package/host/document-store.js +181 -0
- package/host/live-documents.js +524 -0
- package/host/native/file-worker.exe +0 -0
- package/host/native/file-worker.exe.build.json +10 -0
- package/host/native-worker-lease.js +127 -0
- package/host/native-worker-path.ps1 +63 -0
- package/host/package.json +1 -0
- package/host/safe-file.js +137 -0
- package/host/text-codec.js +67 -0
- package/host.json +50 -0
- package/lib/client.js +929 -216
- package/lib/index.js +1506 -1120
- package/package.json +5 -3
- package/renderer-cm6/cm6/app.js +193 -151
- package/renderer-cm6/dsh-bridge.js +216 -5
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Copies one verified helper per generation. No shared cache or DACL changes.
|
|
3
|
+
// 冷路径单助手:一次 PowerShell 进程完成两段资格验证,顺序与原版一致——
|
|
4
|
+
// Q1 base → Node 独占 mkdtemp + 重解析核验 → Q2 从头复查 base+lease → END →
|
|
5
|
+
// 身份/重解析核验 → 之后才读取、独占创建并校验辅助程序副本。规则、realpath、
|
|
6
|
+
// mkdtemp、hash/长度/硬链接/权限/清理与失败即拒全部保留。行协议帧经有界队列
|
|
7
|
+
// 按序消费(早到/合并/post-close 缓冲均合法),END 后验证无多余帧与退出码;
|
|
8
|
+
// 任何失败、超时或 EOF(父进程退出)都有界地终止助手,不留驻 shell。
|
|
9
|
+
const fs=require('node:fs/promises'),path=require('node:path'),crypto=require('node:crypto');
|
|
10
|
+
const {spawn}=require('node:child_process');
|
|
11
|
+
const digest=b=>crypto.createHash('sha256').update(b).digest('hex');
|
|
12
|
+
const STEP_TIMEOUT=10000, OUT_BUDGET=262144, FRAME_LIMIT=64, ERR_BUDGET=8192, EXIT_TIMEOUT=5000;
|
|
13
|
+
const workerPathError=cause=>Object.assign(Error('找不到通过权限检查的辅助程序执行目录'),{code:'MP_NATIVE_WORKER_PATH',cause});
|
|
14
|
+
|
|
15
|
+
function parseReport(line,tag){
|
|
16
|
+
if(typeof line!=='string'||!line.startsWith(tag+' '))throw workerPathError(Error('资格验证助手输出帧无效'));
|
|
17
|
+
let report;
|
|
18
|
+
try{report=JSON.parse(line.slice(tag.length+1));}catch(error){throw workerPathError(error);}
|
|
19
|
+
if(typeof report.base!=='string'||!report.sid||!Array.isArray(report.checks)||!report.checks.length)throw workerPathError(Error('Invalid worker path qualification'));
|
|
20
|
+
return report;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function startHandshake() {
|
|
24
|
+
const script=await fs.readFile(path.join(__dirname,'native-worker-path.ps1'),'utf8');
|
|
25
|
+
const powershell=path.join(process.env.SystemRoot||'C:/Windows','System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
26
|
+
const child=spawn(powershell,['-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],
|
|
27
|
+
{windowsHide:true,stdio:['pipe','pipe','pipe'],env:{...process.env,MP_NATIVE_LEASE_HANDSHAKE:'1',MP_NATIVE_LEASE_PATH:''}});
|
|
28
|
+
const frames=[];let framesBytes=0,buffer='',stderrText='',waiter=null,failed=null,closed=false;
|
|
29
|
+
const wake=()=>{const w=waiter;waiter=null;if(w)w.resolve();};
|
|
30
|
+
const fail=error=>{if(failed)return;failed=error;try{child.kill();}catch{/* 已退 */}wake();};
|
|
31
|
+
child.stdout.setEncoding('utf8');
|
|
32
|
+
child.stdout.on('data',chunk=>{
|
|
33
|
+
if(failed)return;
|
|
34
|
+
buffer+=chunk;
|
|
35
|
+
let end;
|
|
36
|
+
while((end=buffer.indexOf('\n'))>=0){
|
|
37
|
+
const line=buffer.slice(0,end).replace(/\r$/,'');buffer=buffer.slice(end+1);
|
|
38
|
+
frames.push(line);framesBytes+=line.length;
|
|
39
|
+
}
|
|
40
|
+
if(frames.length>FRAME_LIMIT||framesBytes>OUT_BUDGET||buffer.length>OUT_BUDGET)return fail(Error('资格验证助手输出超界'));
|
|
41
|
+
wake();
|
|
42
|
+
});
|
|
43
|
+
child.stderr.setEncoding('utf8');
|
|
44
|
+
child.stderr.on('data',chunk=>{stderrText+=chunk;if(stderrText.length>ERR_BUDGET)fail(Error('资格验证助手错误输出超界'));});
|
|
45
|
+
child.on('error',fail);
|
|
46
|
+
child.on('close',()=>{closed=true;wake();});
|
|
47
|
+
async function step(promise,label){
|
|
48
|
+
let timer=null;
|
|
49
|
+
try{
|
|
50
|
+
return await Promise.race([promise,new Promise((_,reject)=>{
|
|
51
|
+
timer=setTimeout(()=>{const error=Error('资格验证助手'+label+'超时');fail(error);reject(workerPathError(error));},STEP_TIMEOUT);
|
|
52
|
+
if(timer.unref)timer.unref();
|
|
53
|
+
})]);
|
|
54
|
+
} finally { if(timer)clearTimeout(timer); }
|
|
55
|
+
}
|
|
56
|
+
// 有界帧队列:合法帧先缓存后按序消费;child 关闭后仍可消费已完整缓冲的帧。
|
|
57
|
+
async function readLine(){
|
|
58
|
+
for(;;){
|
|
59
|
+
if(frames.length)return frames.shift();
|
|
60
|
+
if(failed)throw workerPathError(failed);
|
|
61
|
+
if(closed)throw workerPathError(Error('资格验证助手提前退出'));
|
|
62
|
+
await step(new Promise(resolve=>{waiter={resolve};}),'等待输出');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const write=frame=>new Promise((resolve,reject)=>{
|
|
66
|
+
if(failed||closed)return reject(workerPathError(failed||Error('资格验证助手提前退出')));
|
|
67
|
+
child.stdin.write(frame,'utf8',error=>error?reject(workerPathError(error)):resolve());
|
|
68
|
+
});
|
|
69
|
+
const finish=async()=>{ // END 已收:无多余帧、干净退出(有界)
|
|
70
|
+
if(frames.length)throw workerPathError(Error('资格验证助手输出多余帧'));
|
|
71
|
+
if(!closed)await step(new Promise(resolve=>{if(closed)return resolve();child.once('close',resolve);}),'退出');
|
|
72
|
+
if(child.exitCode!==0)throw workerPathError(Error('资格验证助手退出码异常:'+child.exitCode));
|
|
73
|
+
};
|
|
74
|
+
const kill=async()=>{ // 有界终止:EOF + kill + 有限等待 + 兜底 SIGKILL
|
|
75
|
+
try{child.stdin.end();}catch{/* 已断 */}
|
|
76
|
+
if(child.exitCode===null&&child.signalCode===null){try{child.kill();}catch{/* 已退 */}}
|
|
77
|
+
await step(new Promise(resolve=>{if(child.exitCode!==null||child.signalCode!==null)return resolve();child.once('close',resolve);}),'终止');
|
|
78
|
+
if(child.exitCode===null&&child.signalCode===null){try{child.kill('SIGKILL');}catch{/* 已退 */}}
|
|
79
|
+
};
|
|
80
|
+
return {child,readLine,write,finish,kill};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function createNativeWorkerLease({name='file-worker.exe'}={}) {
|
|
84
|
+
if(!['file-worker.exe','pdf-worker-guard.exe'].includes(name))throw Error('Unknown native worker');
|
|
85
|
+
const handshake=await startHandshake();
|
|
86
|
+
let dir=null,createdFile=false;
|
|
87
|
+
async function cleanup() {
|
|
88
|
+
const stat=await fs.lstat(dir).catch(e=>{if(e.code!=='ENOENT')throw e;return null;});
|
|
89
|
+
if(!stat)return;
|
|
90
|
+
if(!stat.isDirectory()||stat.isSymbolicLink()||path.resolve(await fs.realpath(dir)).toLowerCase()!==path.resolve(dir).toLowerCase())throw Error('Worker lease changed before cleanup');
|
|
91
|
+
if(createdFile)await fs.unlink(path.join(dir,name)).catch(e=>{if(e.code!=='ENOENT')throw e;});
|
|
92
|
+
await fs.rmdir(dir); // Never recursively remove unknown entries.
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const first=parseReport(await handshake.readLine(),'Q1');
|
|
96
|
+
const base=await fs.realpath(first.base);
|
|
97
|
+
if(path.resolve(base).toLowerCase()!==path.resolve(first.base).toLowerCase())throw Error('Worker base changed during qualification');
|
|
98
|
+
const prefix=path.join(base,'MarkPilot-file-'+process.pid+'-');
|
|
99
|
+
if((prefix+'xxxxxx/'+name).length>240)throw Error('No short worker execution path');
|
|
100
|
+
dir=await fs.mkdtemp(prefix);
|
|
101
|
+
const file=path.join(dir,name);
|
|
102
|
+
// 原精确顺序:先完成第二段资格与身份/重解析核验,之后才写入辅助程序。
|
|
103
|
+
if((await fs.lstat(dir)).isSymbolicLink()||path.resolve(await fs.realpath(dir)).toLowerCase()!==path.resolve(dir).toLowerCase())throw Error('Worker lease redirected');
|
|
104
|
+
await handshake.write('LEASE\t'+Buffer.from(dir,'utf16le').toString('base64')+'\n');
|
|
105
|
+
const second=parseReport(await handshake.readLine(),'Q2');
|
|
106
|
+
if((await handshake.readLine())!=='END')throw Error('Worker handshake footer missing');
|
|
107
|
+
if(path.resolve(second.base).toLowerCase()!==path.resolve(first.base).toLowerCase()
|
|
108
|
+
||path.resolve(second.lease).toLowerCase()!==path.resolve(dir).toLowerCase()
|
|
109
|
+
||second.sid!==first.sid)throw Error('Worker lease identity changed');
|
|
110
|
+
if((await fs.lstat(dir)).isSymbolicLink()||path.resolve(await fs.realpath(dir)).toLowerCase()!==path.resolve(dir).toLowerCase())throw Error('Worker lease redirected');
|
|
111
|
+
await handshake.finish();
|
|
112
|
+
const source=path.join(__dirname,'native',name).replace(/([\\/])app\.asar([\\/])/,'$1app.asar.unpacked$2');
|
|
113
|
+
const [data,stampText]=await Promise.all([fs.readFile(source),fs.readFile(path.join(__dirname,'native',name+'.build.json'),'utf8')]);
|
|
114
|
+
const stamp=JSON.parse(stampText);
|
|
115
|
+
if(!Number.isSafeInteger(stamp.bytes)||stamp.bytes<=0||!/^[a-f0-9]{64}$/.test(stamp.sha256)||data.length!==stamp.bytes||digest(data)!==stamp.sha256)throw Object.assign(Error('文件安全写入辅助程序校验失败'),{code:'MP_FILE_WORKER_INTEGRITY'});
|
|
116
|
+
const handle=await fs.open(file,'wx',0o600);createdFile=true;
|
|
117
|
+
try {await handle.writeFile(data);await handle.sync();} finally {await handle.close();}
|
|
118
|
+
const stat=await fs.lstat(file),copy=await fs.readFile(file);
|
|
119
|
+
if(!stat.isFile()||stat.isSymbolicLink()||stat.nlink!==1||copy.length!==stamp.bytes||digest(copy)!==stamp.sha256)throw Object.assign(Error('文件安全写入辅助副本校验失败'),{code:'MP_FILE_WORKER_INTEGRITY'});
|
|
120
|
+
return {directory:dir,executable:file,sha256:stamp.sha256,qualification:second,cleanup};
|
|
121
|
+
} catch(error) {
|
|
122
|
+
await handshake.kill().catch(()=>{});
|
|
123
|
+
if(dir){try{await cleanup();}catch(cleanupError){error.workerLeaseCleanupError=String(cleanupError);error.workerLease=dir;}}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
module.exports={createNativeWorkerLease};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
$ErrorActionPreference='Stop'
|
|
2
|
+
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
|
3
|
+
$sid=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
4
|
+
$trusted=@($sid,'S-1-5-18','S-1-5-32-544','S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464')
|
|
5
|
+
$checks=[Collections.Generic.List[object]]::new()
|
|
6
|
+
function Assert-WorkerRules($rules,[bool]$private){
|
|
7
|
+
foreach($r in $rules){
|
|
8
|
+
if($r.AccessControlType-ne[Security.AccessControl.AccessControlType]::Allow-or$r.IdentityReference.Value-in$trusted){continue}
|
|
9
|
+
$inheritOnly=($r.PropagationFlags-band[Security.AccessControl.PropagationFlags]::InheritOnly)-ne0
|
|
10
|
+
if($r.IdentityReference.Value-eq'S-1-3-0'-and$inheritOnly){continue}
|
|
11
|
+
$mask=[long]$r.FileSystemRights
|
|
12
|
+
if(((-not$inheritOnly)-and($mask-band 0x500D0040)-ne0)-or($private-and($mask-band 0x500D0156)-ne0)){throw 'Worker directory permits modification by another identity'}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function Inspect-Directory([string]$p,[bool]$private){
|
|
16
|
+
$d=[IO.DirectoryInfo]::new($p)
|
|
17
|
+
if(-not$d.Exists-or($d.Attributes-band[IO.FileAttributes]::ReparsePoint)-ne0){throw 'Worker path must contain real directories'}
|
|
18
|
+
$a=$d.GetAccessControl([Security.AccessControl.AccessControlSections]'Owner,Access')
|
|
19
|
+
$owner=$a.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
20
|
+
if($owner-notin$trusted){throw 'Worker path has an untrusted owner'}
|
|
21
|
+
Assert-WorkerRules ($a.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) $private
|
|
22
|
+
$checks.Add(@{path=$d.FullName;owner=$owner;sddl=$a.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]'Owner,Access')})
|
|
23
|
+
}
|
|
24
|
+
function Get-Qualification([string]$leaseArg){
|
|
25
|
+
$lease=$leaseArg
|
|
26
|
+
if($lease){$lease=[IO.Path]::GetFullPath($lease).TrimEnd('\')}
|
|
27
|
+
$candidates=@([Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData),[IO.Path]::Combine($env:USERPROFILE,'AppData','Local'),$env:USERPROFILE)|Select-Object -Unique
|
|
28
|
+
$base=$null;$rejections=[Collections.Generic.List[string]]::new()
|
|
29
|
+
foreach($candidate in $candidates){
|
|
30
|
+
try{
|
|
31
|
+
$candidate=[IO.Path]::GetFullPath($candidate).TrimEnd('\')
|
|
32
|
+
if($candidate-notmatch'^[A-Za-z]:\\'-or$candidate.Length-gt180){throw 'No short local worker base'}
|
|
33
|
+
if($lease-and-not[IO.Path]::GetDirectoryName($lease).Equals($candidate,[StringComparison]::OrdinalIgnoreCase)){continue}
|
|
34
|
+
$checks.Clear();$p=$candidate
|
|
35
|
+
while($p){Inspect-Directory $p ($p-eq$candidate);$parent=[IO.Directory]::GetParent($p);$p=if($parent){$parent.FullName}else{$null}}
|
|
36
|
+
$base=$candidate;break
|
|
37
|
+
}catch{$rejections.Add($_.Exception.Message)}
|
|
38
|
+
}
|
|
39
|
+
if(-not$base){throw ('No qualified private worker directory: '+($rejections-join'; '))}
|
|
40
|
+
if($lease){
|
|
41
|
+
if(-not[IO.Path]::GetDirectoryName($lease).Equals($base,[StringComparison]::OrdinalIgnoreCase)-or[IO.Path]::GetFileName($lease)-notmatch'^MarkPilot-file-[0-9]+-[A-Za-z0-9]{6}$'){throw 'Worker lease must be a new direct child'}
|
|
42
|
+
Inspect-Directory $lease $true
|
|
43
|
+
if(($lease.Length+16)-gt240){throw 'Worker executable path budget exceeded'}
|
|
44
|
+
}
|
|
45
|
+
[pscustomobject]@{base=$base;lease=$lease;sid=$sid;checks=@($checks);rejectedCandidates=@($rejections)}
|
|
46
|
+
}
|
|
47
|
+
if($env:MP_NATIVE_LEASE_HANDSHAKE-eq'1'){
|
|
48
|
+
# 单助手两段资格验证:先输出 base 资格 JSON 一行;再读 Node 发来的租约路径
|
|
49
|
+
# (LEASE<TAB>base64(UTF-16LE)),按原规则从头复查 base 与 lease 后输出第二份
|
|
50
|
+
# JSON 并明确退出。stdin EOF(父进程退出/关闭)同样立即退出,不留驻 shell。
|
|
51
|
+
$first=Get-Qualification ''
|
|
52
|
+
[Console]::Out.WriteLine('Q1 '+($first|ConvertTo-Json -Depth 5 -Compress))
|
|
53
|
+
$frame=[Console]::In.ReadLine()
|
|
54
|
+
if($null-ne $frame-and $frame-match'^LEASE\t([A-Za-z0-9+/=]+)$'){
|
|
55
|
+
$leasePath=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($Matches[1]))
|
|
56
|
+
$second=Get-Qualification $leasePath
|
|
57
|
+
[Console]::Out.WriteLine('Q2 '+($second|ConvertTo-Json -Depth 5 -Compress))
|
|
58
|
+
}
|
|
59
|
+
[Console]::Out.WriteLine('END')
|
|
60
|
+
exit 0
|
|
61
|
+
}
|
|
62
|
+
# 经典模式(原有行为):MP_NATIVE_LEASE_PATH 环境变量,单次输出 JSON。
|
|
63
|
+
(Get-Qualification $env:MP_NATIVE_LEASE_PATH)|ConvertTo-Json -Depth 5 -Compress
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs=require('node:fs/promises'),path=require('node:path'),crypto=require('node:crypto'),{spawn}=require('node:child_process');
|
|
3
|
+
const {createNativeWorkerLease}=require('./native-worker-lease');
|
|
4
|
+
let worker=null,starting=null,serial=0,closing=false,shutdown=null,operations=0;
|
|
5
|
+
const idleWaiters=new Set();
|
|
6
|
+
let operationHold=null;
|
|
7
|
+
async function startWorker() {
|
|
8
|
+
if(worker?.failed)await worker.exited;
|
|
9
|
+
const lease=await createNativeWorkerLease();
|
|
10
|
+
let child;
|
|
11
|
+
try {child=spawn(lease.executable,[],{cwd:lease.directory,windowsHide:true,stdio:['pipe','pipe','pipe']});}
|
|
12
|
+
catch(error){await lease.cleanup().catch(cleanupError=>{error.workerLeaseCleanupError=String(cleanupError);});throw error;}
|
|
13
|
+
let buffer='',failure=null,current=null,tail=Promise.resolve(),exitResolve,hasExited=false,expectedEOF=false;
|
|
14
|
+
const exited=new Promise(resolve=>{exitResolve=resolve;});
|
|
15
|
+
const unref=()=>{child.unref();for(const stream of [child.stdin,child.stdout,child.stderr])stream.unref?.();};
|
|
16
|
+
unref();
|
|
17
|
+
const fail=error=>{
|
|
18
|
+
if(failure)return;
|
|
19
|
+
failure=error;error.preserveTemp=true;
|
|
20
|
+
if(current){clearTimeout(current.timer);current.reject(error);current=null;}
|
|
21
|
+
child.kill();
|
|
22
|
+
};
|
|
23
|
+
const instance={child,exited,lease,get failed(){return !!failure||hasExited;},rpc(command){
|
|
24
|
+
// Queue deadlines start at dispatch. A failed generation is awaited before replacement.
|
|
25
|
+
const job=tail.catch(()=>{}).then(()=>new Promise((resolve,reject)=>{
|
|
26
|
+
if(failure)return reject(failure);
|
|
27
|
+
if(expectedEOF||hasExited)return reject(Object.assign(Error('文件安全写入进程正在关闭'),{code:'MP_FILE_WORKER_CLOSING',preserveTemp:true}));
|
|
28
|
+
const request=String(++serial),timer=setTimeout(()=>fail(Object.assign(Error('文件安全写入等待超时,磁盘结果待确认,恢复副本已保留'),{code:'MP_FILE_WORKER_TIMEOUT'})),30000);
|
|
29
|
+
current={request,resolve,reject,timer};
|
|
30
|
+
child.stdin.write(JSON.stringify({...command,request})+'\n',error=>{if(error)fail(error);});
|
|
31
|
+
}));
|
|
32
|
+
tail=job;return job;
|
|
33
|
+
},async stop(){
|
|
34
|
+
await tail.catch(()=>{});
|
|
35
|
+
if(!hasExited&&!failure){expectedEOF=true;child.stdin.end();}
|
|
36
|
+
child.ref();for(const stream of [child.stdin,child.stdout,child.stderr])stream.ref?.();
|
|
37
|
+
let timer;
|
|
38
|
+
try {await Promise.race([exited,new Promise((_,reject)=>{timer=setTimeout(()=>{child.kill();reject(Error('文件安全写入进程关闭超时,执行目录保留:'+lease.directory));},5000);})]);}
|
|
39
|
+
finally {clearTimeout(timer);unref();}
|
|
40
|
+
return {pid:child.pid,lease:lease.directory,cleanupError:instance.cleanupError||null};
|
|
41
|
+
}};
|
|
42
|
+
async function finishExit(code) {
|
|
43
|
+
if(hasExited)return;
|
|
44
|
+
hasExited=true;
|
|
45
|
+
if(!(expectedEOF&&code===0&&!current&&!failure))fail(Object.assign(Error('文件安全写入进程已退出:'+code),{code:'MP_FILE_WORKER_EXIT'}));
|
|
46
|
+
try {await lease.cleanup();}catch(error){instance.cleanupError=String(error);console.error('MarkPilot worker lease cleanup:',lease.directory,String(error));}
|
|
47
|
+
if(worker===instance)worker=null;
|
|
48
|
+
exitResolve();
|
|
49
|
+
}
|
|
50
|
+
child.on('error',error=>{fail(error);if(!child.pid)void finishExit(null);});
|
|
51
|
+
child.on('exit',code=>{void finishExit(code);});
|
|
52
|
+
child.stdin.on('error',fail);child.stderr.on('data',()=>{});
|
|
53
|
+
child.stdout.setEncoding('utf8');child.stdout.on('data',chunk=>{
|
|
54
|
+
buffer+=chunk;if(buffer.length>65536){fail(Error('文件安全写入进程回复过长'));return;}
|
|
55
|
+
let end;
|
|
56
|
+
while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end).replace(/^\ufeff/,'');buffer=buffer.slice(end+1);let reply;
|
|
57
|
+
try{reply=JSON.parse(line);}catch{fail(Error('文件安全写入进程返回无效数据'));return;}
|
|
58
|
+
if(!current||reply.request!==current.request||typeof reply.result?.ok!=='boolean'){fail(Error('文件安全写入回复不属于当前操作'));return;}
|
|
59
|
+
const job=current;current=null;clearTimeout(job.timer);job.resolve(reply.result);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
worker=instance;return instance;
|
|
63
|
+
}
|
|
64
|
+
function nativeWorker() {
|
|
65
|
+
if(starting)return starting;
|
|
66
|
+
if(worker&&!worker.failed)return Promise.resolve(worker);
|
|
67
|
+
// Publish the shared admission promise before any asynchronous materialization.
|
|
68
|
+
starting=Promise.resolve().then(startWorker).finally(()=>{starting=null;});
|
|
69
|
+
return starting;
|
|
70
|
+
}
|
|
71
|
+
function finishOperation() {if(--operations===0){clearInterval(operationHold);operationHold=null;for(const resolve of idleWaiters)resolve();idleWaiters.clear();}}
|
|
72
|
+
async function prepareFile(...args) {
|
|
73
|
+
if(process.platform!=='win32')return prepareFileImpl(...args);
|
|
74
|
+
if(closing)throw Object.assign(Error('文件安全写入服务正在关闭'),{code:'MP_FILE_WORKER_CLOSING'});
|
|
75
|
+
if(operations++===0)operationHold=setInterval(()=>{},60000);
|
|
76
|
+
let finished=false;const done=()=>{if(!finished){finished=true;finishOperation();}};
|
|
77
|
+
try {
|
|
78
|
+
const stage=await prepareFileImpl(...args);
|
|
79
|
+
return {
|
|
80
|
+
async commit(){try{const reply=await stage.commit();done();return reply;}catch(error){if(error.preserveTemp)done();throw error;}},
|
|
81
|
+
async abort(){try{return await stage.abort();}finally{done();}},
|
|
82
|
+
};
|
|
83
|
+
} catch(error){done();throw error;}
|
|
84
|
+
}
|
|
85
|
+
// 具名 handler:显式 shutdown 成功后由 shutdownFileWorker 移除同一 handler,
|
|
86
|
+
// 失败(关闭超时等)保留至进程退出兜底重试;HMR 旧代不再挂匿名监听。
|
|
87
|
+
function beforeExitShutdown(){void shutdownFileWorker().catch(error=>console.error('MarkPilot worker shutdown:',String(error)));}
|
|
88
|
+
process.once('beforeExit',beforeExitShutdown);
|
|
89
|
+
function shutdownFileWorker() {
|
|
90
|
+
if(shutdown)return shutdown;
|
|
91
|
+
closing=true;
|
|
92
|
+
const job=(async()=>{
|
|
93
|
+
// Accepted transactions (including asynchronous startup) retain commit/abort admission.
|
|
94
|
+
if(operations)await new Promise(resolve=>idleWaiters.add(resolve));
|
|
95
|
+
if(starting)await starting.catch(()=>{});
|
|
96
|
+
const current=worker;
|
|
97
|
+
return current?current.stop():null;
|
|
98
|
+
})();
|
|
99
|
+
shutdown=job.then(result=>{process.removeListener('beforeExit',beforeExitShutdown);return result;});
|
|
100
|
+
return shutdown;
|
|
101
|
+
}
|
|
102
|
+
// 后台预热与输入重叠;保存仍等待同一已校验工作进程。仅 Windows;关闭开始后 no-op。
|
|
103
|
+
function prepareFileWorker() {
|
|
104
|
+
if(closing||process.platform!=='win32')return Promise.resolve();
|
|
105
|
+
return nativeWorker().then(()=>{},()=>{});
|
|
106
|
+
}
|
|
107
|
+
// Standalone Node tests/tools also close the unref'ed helper on a normal exit.
|
|
108
|
+
function nativeFailure(reply,temp,backup) {
|
|
109
|
+
const error=Object.assign(Error('无法安全替换文件,当前草稿已保留:'+(reply.error||'未知错误')),{code:reply.code||'MP_REPLACE_FAILED',preserveTemp:!!reply.preserveTemp});
|
|
110
|
+
if(error.preserveTemp)error.recoveryPaths={temp,backup};return error;
|
|
111
|
+
}
|
|
112
|
+
async function prepareFileImpl(target,temp,{baseline,output}={}) {
|
|
113
|
+
if(process.platform!=='win32') {
|
|
114
|
+
const before=await fs.stat(target).catch(error=>{if(error.code!=='ENOENT')throw error;return null;});
|
|
115
|
+
const handle=await fs.open(temp,'wx',before?.mode??0o600);await handle.close();
|
|
116
|
+
if(before){await fs.chmod(temp,before.mode);if(before.uid!==process.getuid?.()||before.gid!==process.getgid?.())await fs.chown(temp,before.uid,before.gid);}
|
|
117
|
+
let done=false;
|
|
118
|
+
return {async commit(){await fs.rename(temp,target);done=true;},async abort(){if(!done)await fs.unlink(temp).catch(e=>{if(e.code!=='ENOENT')throw e;});}};
|
|
119
|
+
}
|
|
120
|
+
const id=crypto.randomUUID(),backup=temp+'.backup',native=await nativeWorker();let done=false,recovery=false;
|
|
121
|
+
const expected={exists:!!baseline,sha256:baseline?.sha256||null};
|
|
122
|
+
let prepared;
|
|
123
|
+
try{prepared=await native.rpc({action:'prepare',id,target,temp,backup,expected,output});}
|
|
124
|
+
catch(error){error.recoveryPaths={temp,backup};throw error;}
|
|
125
|
+
if(!prepared?.ok)throw nativeFailure(prepared||{},temp,backup);
|
|
126
|
+
return {
|
|
127
|
+
async commit(){
|
|
128
|
+
recovery=true;
|
|
129
|
+
let reply;try{reply=await native.rpc({action:'commit',id});}catch(error){error.preserveTemp=true;error.recoveryPaths={temp,backup};throw error;}
|
|
130
|
+
if(!reply?.ok){recovery=reply.preserveTemp!==false;throw nativeFailure(reply||{},temp,backup);}
|
|
131
|
+
done=true;recovery=false;
|
|
132
|
+
return {backupRetained:reply.backupRetained?backup:null};
|
|
133
|
+
},
|
|
134
|
+
async abort(){if(done||recovery)return;const reply=await native.rpc({action:'abort',id});if(!reply?.ok)throw nativeFailure(reply||{},temp,backup);done=true;},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
module.exports={prepareFile,shutdownFileWorker,prepareFileWorker};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { TextDecoder } = require('node:util');
|
|
3
|
+
const encodingError = message => Object.assign(new Error(message), { code: 'MP_ENCODING' });
|
|
4
|
+
|
|
5
|
+
function isBinary(bytes) {
|
|
6
|
+
const head = bytes.subarray(0, 4096), b = i => head[i];
|
|
7
|
+
const begins = value => head.length >= value.length && head.subarray(0, value.length).equals(Buffer.from(value, 'binary'));
|
|
8
|
+
const bmp = begins('BM') && head.length >= 26 && head.readUInt16LE(6) === 0 && head.readUInt16LE(8) === 0
|
|
9
|
+
&& [12, 40, 52, 56, 64, 108, 124].includes(head.readUInt32LE(14));
|
|
10
|
+
const magic = begins('\x89PNG\r\n\x1a\n')
|
|
11
|
+
|| (b(0) === 0xff && b(1) === 0xd8 && b(2) === 0xff)
|
|
12
|
+
|| begins('GIF87a') || begins('GIF89a') || bmp
|
|
13
|
+
|| /^%PDF-\d\.\d/.test(head.subarray(0, 8).toString('ascii'))
|
|
14
|
+
|| begins('PK\x03\x04') || begins('PK\x05\x06') || begins('PK\x07\x08')
|
|
15
|
+
|| begins('\x7fELF')
|
|
16
|
+
|| (begins('RIFF') && head.length >= 12 && ['WEBP', 'WAVE', 'AVI '].includes(head.subarray(8, 12).toString('ascii')))
|
|
17
|
+
|| (b(0) === 0 && b(1) === 0 && b(2) === 1 && b(3) === 0);
|
|
18
|
+
let printable = 0;
|
|
19
|
+
for (const c of head) if (c === 9 || c === 10 || c === 13 || (c >= 32 && c < 127) || c >= 128) printable++;
|
|
20
|
+
return magic || (head.includes(0) && head.length && printable / head.length < 0.8);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Open and reload share strict decoding. Never invent replacement characters for bad bytes.
|
|
24
|
+
function decodeDocument(bytes, file = '') {
|
|
25
|
+
if ((bytes[0] === 0xff && bytes[1] === 0xfe && bytes[2] === 0 && bytes[3] === 0)
|
|
26
|
+
|| (bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 0xfe && bytes[3] === 0xff)) {
|
|
27
|
+
throw encodingError('暂不支持 UTF-32 文件,请先转换为 UTF-8 或带 BOM 的 UTF-16:' + file);
|
|
28
|
+
}
|
|
29
|
+
let encoding = 'utf8', bom = false, body = bytes;
|
|
30
|
+
if ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)) {
|
|
31
|
+
encoding = bytes[0] === 0xff ? 'utf16le' : 'utf16be'; bom = true; body = bytes.subarray(2);
|
|
32
|
+
if (body.length % 2) throw encodingError('UTF-16 文件字节数不完整,已停止打开以保护原文件:' + file);
|
|
33
|
+
} else {
|
|
34
|
+
if (isBinary(bytes)) throw Object.assign(new Error('这是一个二进制文件,MarkPilot 只打开文本文件:' + file), { code: 'MP_BINARY' });
|
|
35
|
+
if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { bom = true; body = bytes.subarray(3); }
|
|
36
|
+
}
|
|
37
|
+
let text;
|
|
38
|
+
try {
|
|
39
|
+
// BOM bytes were removed explicitly; another U+FEFF is real document content.
|
|
40
|
+
text = new TextDecoder(encoding === 'utf8' ? 'utf-8' : encoding === 'utf16le' ? 'utf-16le' : 'utf-16be', { fatal: true, ignoreBOM: true }).decode(body);
|
|
41
|
+
} catch {
|
|
42
|
+
throw encodingError('文件不是完整有效的 ' + (encoding === 'utf8' ? 'UTF-8' : 'UTF-16') + ' 文本,已停止打开以保护原文件:' + file);
|
|
43
|
+
}
|
|
44
|
+
const crlf = (text.match(/\r\n/g) || []).length, lf = (text.match(/\n/g) || []).length - crlf;
|
|
45
|
+
const cr = (text.match(/\r/g) || []).length - crlf;
|
|
46
|
+
const eol = crlf > 0 && crlf >= lf && crlf >= cr ? '\r\n' : cr > lf ? '\r' : '\n';
|
|
47
|
+
return { text: text.replace(/\r\n?|\n/g, '\n'), bom, eol, encoding };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function encodeDocument(text, { bom = false, eol = '\n', encoding = 'utf8' } = {}) {
|
|
51
|
+
if (!['utf8', 'utf16le', 'utf16be'].includes(encoding)) throw encodingError('不支持的保存编码:' + encoding);
|
|
52
|
+
// Buffer.from silently repairs invalid UTF-8 strings but can emit invalid UTF-16.
|
|
53
|
+
// Refuse both, retaining the editor draft and previous disk bytes.
|
|
54
|
+
for (let i = 0; i < text.length; i++) {
|
|
55
|
+
const c = text.charCodeAt(i);
|
|
56
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
57
|
+
const next = text.charCodeAt(++i);
|
|
58
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) throw encodingError('文档包含不完整的 Unicode 字符,已停止保存。请删除该字符后重试或撤销最近输入。');
|
|
59
|
+
} else if (c >= 0xdc00 && c <= 0xdfff) throw encodingError('文档包含不完整的 Unicode 字符,已停止保存。请删除该字符后重试或撤销最近输入。');
|
|
60
|
+
}
|
|
61
|
+
const lineBreak = ['\n', '\r\n', '\r'].includes(eol) ? eol : '\n';
|
|
62
|
+
const value = (bom ? '\ufeff' : '') + text.replace(/\r\n?|\n/g, lineBreak);
|
|
63
|
+
const bytes = Buffer.from(value, encoding === 'utf8' ? 'utf8' : 'utf16le');
|
|
64
|
+
return encoding === 'utf16be' ? bytes.swap16() : bytes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { decodeDocument, encodeDocument };
|
package/host.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": "../MarkPilot-CM6/shell",
|
|
3
|
+
"files": [
|
|
4
|
+
{
|
|
5
|
+
"path": "document-store.js",
|
|
6
|
+
"bytes": 14019,
|
|
7
|
+
"sha256": "34bd34a40fef36499e36b0f50f8357a9290c1040dad8bbe8ff7da29405a30f20"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"path": "live-documents.js",
|
|
11
|
+
"bytes": 30336,
|
|
12
|
+
"sha256": "2f843e6a5e143a72c5f08092f8a09570a48b01c35d204d334fd88026917deaf3"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"path": "text-codec.js",
|
|
16
|
+
"bytes": 4308,
|
|
17
|
+
"sha256": "0640a34938bef9037b25bd941cf9569b2513c3cef2ed97f090fcb760084ea4ac"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "safe-file.js",
|
|
21
|
+
"bytes": 8371,
|
|
22
|
+
"sha256": "d6878ec47d8670232171a130250340ba8ac4707baebe8f6b3c4ed9f8e9106915"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"path": "native-worker-lease.js",
|
|
26
|
+
"bytes": 8612,
|
|
27
|
+
"sha256": "b8dc6b7af75dd685130a26c250c45d17f72d01c523768783a4fa617604857b81"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"path": "native-worker-path.ps1",
|
|
31
|
+
"bytes": 4081,
|
|
32
|
+
"sha256": "0534a5a275cb20f4960feb2554d5b1ac6b383999a5fb60e3789df6ddd702411b"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"path": "native/file-worker.exe",
|
|
36
|
+
"bytes": 19968,
|
|
37
|
+
"sha256": "c6f1bf4e5a0335ff066eca2597b273a32e39d7fe9762c566b393ea01c542ce22"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"path": "native/file-worker.exe.build.json",
|
|
41
|
+
"bytes": 410,
|
|
42
|
+
"sha256": "88c38aa21f9ef494240e42c6ac55b82f6e2d4aff02404aa5824ee235cff39b6b"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"path": "package.json",
|
|
46
|
+
"bytes": 20,
|
|
47
|
+
"sha256": "dbf8353f77358bc12169b7bb7301e1978d5b503e002ee927229a8993672818fc"
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|