@petercjl/codex-own 0.3.0 → 0.4.0
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 +9 -1
- package/capabilities.json +16 -3
- package/lib/app-update.mjs +6 -3
- package/lib/content-blocks.mjs +121 -0
- package/lib/manager.mjs +12 -4
- package/lib/npm-assets.mjs +17 -3
- package/package.json +852 -85
- package/release.json +851 -84
- package/skills/codex-own/SKILL.md +4 -0
package/README.md
CHANGED
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
日常应用内更新使用随 App 交付的更新组件,无需手动更新全局 npm 管理工具。API Key、配置、对话和语言偏好会保留。断网时当前 App 保持可用;下载失败可以重试,已校验分片会复用;已准备好的更新会在下次打开时恢复。旧版本首次升级到带更新组件的版本,以及故障恢复,可以使用下文的终端更新命令。
|
|
10
10
|
|
|
11
|
+
## 增量下载
|
|
12
|
+
|
|
13
|
+
从 0.4.0 起,更新前会扫描已安装的 codex-own 程序,复用与新版相同的内容块,只从 npm 下载缺少的块。支持跳过版本;首次安装仍下载完整资源。进度会显示“复用多少块、需要下载多少 MiB、全量多少 MiB”,结果中的 `transfer` 保留这些统计。少量代码改动通常只影响部分块;上游引擎、大型二进制或签名资源变化时,下载量会相应增加。
|
|
14
|
+
|
|
15
|
+
0.3.x 首次迁移:先退出 codex-own,使用下文对应系统的终端命令更新 npm 管理工具,再执行桌面更新。升级后的管理工具即可从现有程序复用内容。之后直接使用应用内“更新”菜单,随 App 交付的更新组件会自动执行相同的增量下载。
|
|
16
|
+
|
|
17
|
+
本地扫描只读取程序目录,密钥、配置和对话继续保存在独立数据目录。下载结束后在本地重建完整归档、核对 SHA256,再备份并切换程序。请继续预留完整安装和备份的磁盘空间。
|
|
18
|
+
|
|
11
19
|
## 在 Mac 上通过 Terminal 安装和更新
|
|
12
20
|
|
|
13
21
|
适用于 Apple 芯片 Mac(ARM64)。从 Node.js 官网安装 LTS 版本(要求 Node.js 22 或以上,包含 npm),重新打开 Terminal。建议预留 10 GB 空间,用于下载、解压和备份。
|
|
@@ -82,7 +90,7 @@ $codexOwn = Join-Path (npm.cmd prefix --global) "codex-own.cmd"
|
|
|
82
90
|
& $codexOwn install --yes
|
|
83
91
|
```
|
|
84
92
|
|
|
85
|
-
管理工具和桌面资源全部从 registry.npmjs.org 下载。Windows
|
|
93
|
+
管理工具和桌面资源全部从 registry.npmjs.org 下载。Windows 桌面资源约 1 GB,具体大小随版本变化。最多并行下载 4 个分片,安装时显示分片编号、下载百分比和已接收大小,然后显示校验、解压、安装。看到“安装完成”和 ok: true 才表示完成。
|
|
86
94
|
|
|
87
95
|
若已有手动安装的 codex-own,先退出应用,将第二步的安装命令替换为以下接管命令;原安装会被备份。
|
|
88
96
|
|
package/capabilities.json
CHANGED
|
@@ -25,12 +25,25 @@
|
|
|
25
25
|
"runtimeHosts": [
|
|
26
26
|
"registry.npmjs.org"
|
|
27
27
|
],
|
|
28
|
-
"desktopAssets": "content-
|
|
29
|
-
"cache": "verified
|
|
28
|
+
"desktopAssets": "canonical-tar-cdc-v1; content-defined raw program blocks compressed and distributed as npm payload prereleases",
|
|
29
|
+
"cache": "SHA256-verified blocks; seed matching blocks from managed installed program; download missing content only",
|
|
30
30
|
"progress": "stderr; final result JSON on stdout",
|
|
31
31
|
"idleTimeoutSeconds": 30,
|
|
32
32
|
"downloadAttempts": 3,
|
|
33
|
-
"parallelDownloads": 4
|
|
33
|
+
"parallelDownloads": 4,
|
|
34
|
+
"incremental": {
|
|
35
|
+
"format": "canonical-tar-cdc-v1",
|
|
36
|
+
"rawBlockMinMiB": 16,
|
|
37
|
+
"rawBlockMaxMiB": 64,
|
|
38
|
+
"baseVersionRequired": false,
|
|
39
|
+
"firstInstall": "all required blocks",
|
|
40
|
+
"migration": "upgrade npm manager once for desktop builds older than 0.4.0",
|
|
41
|
+
"statistics": [
|
|
42
|
+
"reusedBlocks",
|
|
43
|
+
"downloadBytes",
|
|
44
|
+
"fullDownloadBytes"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
34
47
|
},
|
|
35
48
|
"desktopUpdater": {
|
|
36
49
|
"source": "https://registry.npmjs.org/@petercjl%2fcodex-own/latest",
|
package/lib/app-update.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {latest,manifestFor,validateManifest,paths,state,locked,rejectLinks,exclusive,digestFile,tarExecutable,install,fail,running,launch} from './manager.mjs';
|
|
4
4
|
import {assembleNpmAsset} from './npm-assets.mjs';
|
|
5
|
+
import {reuseInstalledBlocks} from './content-blocks.mjs';
|
|
5
6
|
|
|
6
7
|
// Desktop release versions use the stable npm channel, independently of upstream's app version.
|
|
7
8
|
export function isNewer(candidate,current) {
|
|
@@ -27,12 +28,14 @@ export async function prepareUpdate(current,{p=paths(),resolve=latest,manifest=m
|
|
|
27
28
|
const dir=fs.mkdtempSync(path.join(base,'update-')),archive=path.join(dir,'desktop.tar.gz');
|
|
28
29
|
const cache=path.join(p.manager,'npm-assets');rejectLinks(cache);
|
|
29
30
|
try{
|
|
30
|
-
|
|
31
|
+
// The app may stay open: only read program resources, never user data.
|
|
32
|
+
if(p.target){rejectLinks(p.target);await reuseInstalledBlocks(asset,p.target,cache,{report,signal});}
|
|
33
|
+
const transfer=await assemble(asset,archive,cache,tarExecutable(),{report,signal});
|
|
31
34
|
// Verify even when a download adapter is supplied.
|
|
32
35
|
if(fs.statSync(archive).size!==asset.size||await digestFile(archive)!==asset.sha256)fail('CHECKSUM_MISMATCH','Prepared update checksum mismatch');
|
|
33
36
|
const receipt=path.join(dir,'receipt.json');
|
|
34
|
-
exclusive(receipt,{schema:1,current,version:m.version,platform:p.platform,manifest:m});
|
|
35
|
-
return {...checked,receipt};
|
|
37
|
+
exclusive(receipt,{schema:1,current,version:m.version,platform:p.platform,manifest:m,...(transfer?{transfer}:{})});
|
|
38
|
+
return {...checked,receipt,...(transfer?{transfer}:{})};
|
|
36
39
|
}catch(e){fs.rmSync(dir,{recursive:true,force:true});throw e;}
|
|
37
40
|
});
|
|
38
41
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Versioned, deterministic transport shared by build, local reuse and release.
|
|
2
|
+
// npm compresses each raw block; compressing the whole app first destroys reuse.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import {pipeline} from 'node:stream/promises';
|
|
7
|
+
|
|
8
|
+
export const TRANSPORT = 'canonical-tar-cdc-v1';
|
|
9
|
+
export const BLOCKS = Object.freeze({min:16*1048576, mask:0x1ffffff, max:64*1048576});
|
|
10
|
+
const sha = b => crypto.createHash('sha256').update(b).digest('hex');
|
|
11
|
+
const abort = signal => { if(signal?.aborted) throw Object.assign(Error('Update cancelled'), {code:'CANCELLED'}); };
|
|
12
|
+
const gear = Array.from({length:256},(_,i)=>crypto.createHash('sha256').update(`codex-own-cdc-v1:${i}`).digest().readUInt32LE(0));
|
|
13
|
+
const fileBoundary=()=>Object.assign(Buffer.alloc(0),{canonicalFileBoundary:true});
|
|
14
|
+
|
|
15
|
+
function header(name, size, type, mode, link='') {
|
|
16
|
+
const b=Buffer.alloc(512);
|
|
17
|
+
const text=(s,at,len)=>{if(Buffer.byteLength(s)>len)throw Error('Tar field overflow');b.write(s,at,len,'utf8');};
|
|
18
|
+
const oct=(n,at,len)=>text(n.toString(8).padStart(len-1,'0')+'\0',at,len);
|
|
19
|
+
text(name,0,100);oct(mode,100,8);oct(0,108,8);oct(0,116,8);
|
|
20
|
+
oct(size,124,12);oct(0,136,12);b.fill(32,148,156);text(type,156,1);
|
|
21
|
+
text(link,157,100);text('ustar\0',257,6);text('00',263,2);
|
|
22
|
+
const sum=b.reduce((a,v)=>a+v,0);text(sum.toString(8).padStart(6,'0')+'\0 ',148,8);
|
|
23
|
+
return b;
|
|
24
|
+
}
|
|
25
|
+
function paxRecord(key,value) {
|
|
26
|
+
const body=` ${key}=${value}\n`;let n=Buffer.byteLength(body)+1;
|
|
27
|
+
while(String(n).length+Buffer.byteLength(body)!==n)n=String(n).length+Buffer.byteLength(body);
|
|
28
|
+
return Buffer.from(String(n)+body);
|
|
29
|
+
}
|
|
30
|
+
function* entryHeaders(name,size,type,mode,link='') {
|
|
31
|
+
const records=[];
|
|
32
|
+
if(Buffer.byteLength(name)>100)records.push(paxRecord('path',name));
|
|
33
|
+
if(Buffer.byteLength(link)>100)records.push(paxRecord('linkpath',link));
|
|
34
|
+
if(records.length){
|
|
35
|
+
const data=Buffer.concat(records),id=sha(Buffer.from(name));
|
|
36
|
+
yield header('PaxHeaders/'+id,data.length,'x',0o644);yield data;
|
|
37
|
+
if(data.length%512)yield Buffer.alloc(512-data.length%512);
|
|
38
|
+
name='entry-'+id;
|
|
39
|
+
}
|
|
40
|
+
yield header(name,size,type,mode,Buffer.byteLength(link)>100?'':link);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Stable order, permissions and timestamps across CI and an installed app.
|
|
44
|
+
// Links are recorded, never traversed. No host metadata or user data is bundled.
|
|
45
|
+
export async function* canonicalTar(root,entry,{signal}={}) {
|
|
46
|
+
if(!['app','codex-own.app'].includes(entry))throw Error('Unexpected application entry');
|
|
47
|
+
root=path.resolve(root);
|
|
48
|
+
if(!fs.lstatSync(root).isDirectory()||fs.lstatSync(root).isSymbolicLink())throw Error('Expected a real application directory');
|
|
49
|
+
async function* walk(file,name){
|
|
50
|
+
abort(signal);const st=fs.lstatSync(file);
|
|
51
|
+
if(st.isSymbolicLink()){
|
|
52
|
+
const link=fs.readlinkSync(file),resolved=path.resolve(path.dirname(file),link);
|
|
53
|
+
if(path.isAbsolute(link)||(resolved!==root&&!resolved.startsWith(root+path.sep)))throw Object.assign(Error('Escaping application symlink'),{code:'UNSAFE_PATH'});
|
|
54
|
+
yield* entryHeaders(name,0,'2',0o777,link);
|
|
55
|
+
}else if(st.isDirectory()){
|
|
56
|
+
yield* entryHeaders(name+'/',0,'5',0o755);
|
|
57
|
+
for(const n of fs.readdirSync(file).sort())yield* walk(path.join(file,n),name+'/'+n);
|
|
58
|
+
}else if(st.isFile()){
|
|
59
|
+
// A changed file must not shift the block grid of the next large binary.
|
|
60
|
+
if(st.size>=BLOCKS.min)yield fileBoundary();
|
|
61
|
+
const mode=entry==='codex-own.app'&&(st.mode&0o111)?0o755:0o644;
|
|
62
|
+
yield* entryHeaders(name,st.size,'0',mode);let size=0;
|
|
63
|
+
for await(const b of fs.createReadStream(file)){abort(signal);size+=b.length;yield b;}
|
|
64
|
+
if(size!==st.size)throw Object.assign(Error('Application changed during local scan'),{code:'LOCAL_APP_CHANGED'});
|
|
65
|
+
if(size%512)yield Buffer.alloc(512-size%512);
|
|
66
|
+
if(st.size>=BLOCKS.min)yield fileBoundary();
|
|
67
|
+
}else throw Object.assign(Error('Unsupported application file'),{code:'UNSAFE_PATH'});
|
|
68
|
+
}
|
|
69
|
+
yield* walk(root,entry);yield Buffer.alloc(1024);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Boundaries depend on content, so insertion in an asar does not shift all blocks.
|
|
73
|
+
export async function* contentBlocks(stream,{min=BLOCKS.min,mask=BLOCKS.mask,max=BLOCKS.max,signal}={}) {
|
|
74
|
+
if(!Number.isInteger(min)||min<1||!Number.isInteger(max)||max<min||max>128*1048576||!Number.isInteger(mask)||mask<1)throw Error('Invalid block parameters');
|
|
75
|
+
let pieces=[],size=0,h=0;
|
|
76
|
+
for await(const buffer of stream){
|
|
77
|
+
abort(signal);
|
|
78
|
+
if(buffer.canonicalFileBoundary){if(size)yield Buffer.concat(pieces,size);pieces=[];size=0;h=0;continue;}
|
|
79
|
+
let start=0;
|
|
80
|
+
for(let i=0;i<buffer.length;i++){
|
|
81
|
+
h=((h<<1)+gear[buffer[i]])>>>0;size++;
|
|
82
|
+
if(size>=min&&((h&mask)===0||size>=max)){
|
|
83
|
+
pieces.push(buffer.subarray(start,i+1));yield Buffer.concat(pieces,size);
|
|
84
|
+
pieces=[];size=0;h=0;start=i+1;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if(start<buffer.length)pieces.push(buffer.subarray(start));
|
|
88
|
+
}
|
|
89
|
+
if(size)yield Buffer.concat(pieces,size);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function writeCanonicalArchive(root,entry,dest,{signal}={}) {
|
|
93
|
+
const blocks=[],digest=crypto.createHash('sha256');let size=0;
|
|
94
|
+
async function* bytes(){
|
|
95
|
+
for await(const b of contentBlocks(canonicalTar(root,entry,{signal}),{signal})){
|
|
96
|
+
blocks.push({sha256:sha(b),size:b.length,offset:size});size+=b.length;digest.update(b);yield b;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
await pipeline(bytes(),fs.createWriteStream(dest,{flags:'wx',mode:0o600}),...(signal?[{signal}]:[]));
|
|
100
|
+
return {transport:TRANSPORT,size,sha256:digest.digest('hex'),blocks};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function reuseInstalledBlocks(asset,root,cache,{signal,report=()=>{}}={}) {
|
|
104
|
+
if(asset.transport!==TRANSPORT||!root||!fs.existsSync(root))return {seededBlocks:0,seededBytes:0};
|
|
105
|
+
fs.mkdirSync(cache,{recursive:true,mode:0o700});
|
|
106
|
+
const wanted=new Map(asset.parts.map(p=>[p.sha256,p.size]));
|
|
107
|
+
let seededBlocks=0,seededBytes=0,scanned=0,last=0;
|
|
108
|
+
report('正在扫描已安装程序,匹配可复用的内容块');
|
|
109
|
+
for await(const b of contentBlocks(canonicalTar(root,asset.entry,{signal}),{signal})){
|
|
110
|
+
scanned+=b.length;const hash=sha(b);
|
|
111
|
+
if(wanted.get(hash)===b.length){
|
|
112
|
+
const file=path.join(cache,hash+'.bin');
|
|
113
|
+
// Never replace an existing cache entry here. Assembly verifies it again.
|
|
114
|
+
if(!fs.existsSync(file))fs.writeFileSync(file,b,{flag:'wx',mode:0o600});
|
|
115
|
+
wanted.delete(hash);seededBlocks++;seededBytes+=b.length;
|
|
116
|
+
}
|
|
117
|
+
if(Date.now()-last>2000){report(`本地扫描 ${Math.round(scanned/1048576)} MiB · 匹配 ${seededBlocks} 个内容块`);last=Date.now();}
|
|
118
|
+
}
|
|
119
|
+
report(`本地匹配完成:可复用 ${seededBlocks} 个内容块`);
|
|
120
|
+
return {seededBlocks,seededBytes};
|
|
121
|
+
}
|
package/lib/manager.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import crypto from 'node:crypto';
|
|
|
6
6
|
import {execFileSync, spawn} from 'node:child_process';
|
|
7
7
|
import {networkFetch, verifiedDownload, progress} from './network.mjs';
|
|
8
8
|
import {validateParts, assembleNpmAsset} from './npm-assets.mjs';
|
|
9
|
+
import {reuseInstalledBlocks} from './content-blocks.mjs';
|
|
9
10
|
|
|
10
11
|
export const packageRoot = path.resolve(import.meta.dirname, '..');
|
|
11
12
|
export const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot,'package.json'),'utf8'));
|
|
@@ -51,10 +52,13 @@ export function unpack(archive,dest) {
|
|
|
51
52
|
function walk(p){for(const x of fs.readdirSync(p,{withFileTypes:true})){const f=path.join(p,x.name);if(x.isSymbolicLink()){const resolved=path.resolve(path.dirname(f),fs.readlinkSync(f));if(!resolved.startsWith(dest+path.sep))fail('UNSAFE_ARCHIVE','Escaping symlink');}else if(x.isDirectory())walk(f);}}
|
|
52
53
|
walk(dest);
|
|
53
54
|
}
|
|
55
|
+
export function macOwnProcessIds(lines) {
|
|
56
|
+
return lines.filter(l=>l.includes('codex-own.app/Contents/')&&!l.includes(' ps -')&&!l.includes('/Contents/Resources/cua_node/')&&!/\/Contents\/MacOS\/WinAppHelper(?:\s|$)/.test(l)).map(l=>l.trim().split(/\s+/)[0]);
|
|
57
|
+
}
|
|
54
58
|
export function running(p=paths()) {
|
|
55
59
|
if(process.platform==='darwin'){
|
|
56
60
|
const lines=execFileSync('ps',['-axo','pid=,command='],{encoding:'utf8'}).split('\n');
|
|
57
|
-
return lines
|
|
61
|
+
return macOwnProcessIds(lines);
|
|
58
62
|
}
|
|
59
63
|
const out=execFileSync('powershell.exe',['-NoProfile','-Command',"@(Get-Process -Name 'codex-own' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id) -join ','"],{encoding:'utf8'}).trim();return out?out.split(','):[];
|
|
60
64
|
}
|
|
@@ -102,9 +106,13 @@ export async function install({version,adopt=false,archive,manifest,expectedCurr
|
|
|
102
106
|
if(expectedCurrent!==undefined&&state(p)?.version!==expectedCurrent)fail('INSTALLATION_CHANGED','Installation changed after the update was prepared; check again');
|
|
103
107
|
if(state(p)?.version===m.version&&!archive)return {ok:true,unchanged:true,version:m.version};
|
|
104
108
|
if(running(p).length)fail('APP_RUNNING','Quit codex-own before install/update');
|
|
105
|
-
const stage=fs.mkdtempSync(path.join(p.manager,'stage-'));let f=archive;
|
|
109
|
+
const stage=fs.mkdtempSync(path.join(p.manager,'stage-'));let f=archive,transfer;
|
|
106
110
|
if(f){if(fs.statSync(f).size!==a.size||await digestFile(f)!==a.sha256)fail('CHECKSUM_MISMATCH','Local archive checksum mismatch');}
|
|
107
|
-
else{if(m.schema!==2)fail('NPM_ASSETS_REQUIRED','Update @petercjl/codex-own to the latest npm-only release.');f=path.join(stage,'app.tar
|
|
111
|
+
else{if(m.schema!==2)fail('NPM_ASSETS_REQUIRED','Update @petercjl/codex-own to the latest npm-only release.');f=path.join(stage,'app.tar');const cache=path.join(p.manager,'npm-assets');rejectLinks(cache);
|
|
112
|
+
if(state(p)?.identity==='local.codex-own'&&fs.existsSync(p.target)&&appIdentity(p.target)==='local.codex-own'){
|
|
113
|
+
rejectLinks(p.target);await reuseInstalledBlocks(a,p.target,cache,{report:progress});
|
|
114
|
+
}
|
|
115
|
+
transfer=await assembleNpmAsset(a,f,cache,tarExecutable());}
|
|
108
116
|
progress('校验通过,正在解压桌面程序');
|
|
109
117
|
const content=path.join(stage,'unpacked');unpack(f,content);const source=path.join(content,a.entry);
|
|
110
118
|
if(process.platform==='darwin')execFileSync('codesign',['--verify','--deep','--strict',source],{stdio:'pipe'});
|
|
@@ -112,7 +120,7 @@ export async function install({version,adopt=false,archive,manifest,expectedCurr
|
|
|
112
120
|
const result=switchApp({p,source,version:m.version,adopt});
|
|
113
121
|
if(process.platform==='darwin')execFileSync('/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister',['-f',p.target],{stdio:'pipe'});
|
|
114
122
|
else shortcut(p);
|
|
115
|
-
fs.rmSync(stage,{recursive:true,force:true});progress('安装完成');return {...result,downloadSource:archive?'local-archive':'registry.npmjs.org'};
|
|
123
|
+
fs.rmSync(stage,{recursive:true,force:true});progress('安装完成');return {...result,downloadSource:archive?'local-archive':'registry.npmjs.org',...(transfer?{transfer}:{})};
|
|
116
124
|
});
|
|
117
125
|
}
|
|
118
126
|
export function shortcut(p) {
|
package/lib/npm-assets.mjs
CHANGED
|
@@ -3,12 +3,14 @@ import path from 'node:path';
|
|
|
3
3
|
import {execFileSync} from 'node:child_process';
|
|
4
4
|
import {pipeline} from 'node:stream/promises';
|
|
5
5
|
import {fileHash, verifiedDownload, progress} from './network.mjs';
|
|
6
|
+
import {TRANSPORT} from './content-blocks.mjs';
|
|
6
7
|
|
|
7
8
|
export const payloadVersion = sha => `0.0.0-payload.h${sha}`;
|
|
8
9
|
export const payloadURL = sha => `https://registry.npmjs.org/@petercjl/codex-own/-/codex-own-${payloadVersion(sha)}.tgz`;
|
|
9
10
|
export function validateParts(asset) {
|
|
10
11
|
const fail = () => { throw Object.assign(new Error('Invalid npm asset manifest'), {code:'INVALID_MANIFEST'}); };
|
|
11
|
-
if (
|
|
12
|
+
if (asset.transport !== undefined && asset.transport !== TRANSPORT) fail();
|
|
13
|
+
if (!Array.isArray(asset.parts) || asset.parts.length < 1 || asset.parts.length > 1024) fail();
|
|
12
14
|
let size = 0;
|
|
13
15
|
for (const p of asset.parts) {
|
|
14
16
|
if (!/^[a-f0-9]{64}$/.test(p.sha256) || !/^[a-f0-9]{64}$/.test(p.archiveSha256)
|
|
@@ -32,11 +34,22 @@ export async function assembleNpmAsset(asset, dest, cache, tar, {download = veri
|
|
|
32
34
|
const cancel=()=>controller.abort();signal?.addEventListener('abort',cancel,{once:true});
|
|
33
35
|
if(signal?.aborted)controller.abort();
|
|
34
36
|
try{
|
|
37
|
+
const cached = new Set();
|
|
38
|
+
for(const part of asset.parts){
|
|
39
|
+
const file=path.join(cache,part.sha256+'.bin');safeCache(file);
|
|
40
|
+
if(fs.existsSync(file)&&fs.statSync(file).size===part.size&&await fileHash(file)===part.sha256)cached.add(part.sha256);
|
|
41
|
+
}
|
|
42
|
+
const uniqueParts=[...new Map(asset.parts.map(part=>[part.sha256,part])).values()];
|
|
43
|
+
const stats={transport:asset.transport||'legacy',totalBlocks:asset.parts.length,reusedBlocks:asset.parts.filter(p=>cached.has(p.sha256)).length,
|
|
44
|
+
downloadBlocks:uniqueParts.filter(p=>!cached.has(p.sha256)).length,
|
|
45
|
+
downloadBytes:uniqueParts.filter(p=>!cached.has(p.sha256)).reduce((n,p)=>n+p.archiveSize,0),
|
|
46
|
+
fullDownloadBytes:uniqueParts.reduce((n,p)=>n+p.archiveSize,0)};
|
|
47
|
+
report(`更新资源:复用 ${stats.reusedBlocks}/${stats.totalBlocks} 块 · 需下载 ${(stats.downloadBytes/1048576).toFixed(1)} MiB / 全量 ${(stats.fullDownloadBytes/1048576).toFixed(1)} MiB`);
|
|
35
48
|
async function fetchPart(part, i) {
|
|
36
49
|
report(`npm 资源分片 ${i+1}/${asset.parts.length}`);
|
|
37
50
|
const file = path.join(cache, `${part.sha256}.bin`);
|
|
38
51
|
safeCache(file);
|
|
39
|
-
if (
|
|
52
|
+
if (cached.has(part.sha256)) {
|
|
40
53
|
report(`[${i+1}/${asset.parts.length}] 复用已校验分片`); return file;
|
|
41
54
|
}
|
|
42
55
|
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
@@ -66,11 +79,12 @@ export async function assembleNpmAsset(asset, dest, cache, tar, {download = veri
|
|
|
66
79
|
if(failure)throw failure;
|
|
67
80
|
if(controller.signal.aborted)throw Object.assign(Error('Download cancelled'),{code:'CANCELLED'});
|
|
68
81
|
const files=asset.parts.map(part=>completed.get(part.sha256));
|
|
69
|
-
report('
|
|
82
|
+
report('在本地重建并校验完整桌面安装包');
|
|
70
83
|
const output = fs.createWriteStream(dest, {flags:'wx',mode:0o600});
|
|
71
84
|
async function* chunks() { for (const f of files) yield* fs.createReadStream(f); }
|
|
72
85
|
await pipeline(chunks(), output,{signal:controller.signal});
|
|
73
86
|
if (fs.statSync(dest).size !== asset.size || await fileHash(dest) !== asset.sha256)
|
|
74
87
|
throw Object.assign(new Error('Assembled desktop checksum mismatch'), {code:'CHECKSUM_MISMATCH'});
|
|
88
|
+
return stats;
|
|
75
89
|
}finally{signal?.removeEventListener('abort',cancel);}
|
|
76
90
|
}
|