fix(geocaching): retain staged edits during SD contention

This commit is contained in:
liu weikai committed 2026-10-15 13:34:58 +08:00
1 parent 8dbb112f73
commit d953528584
7 files changed
+59 -13

No files matched your search

BIN
View File
Binary file not shown.
@@ -2,7 +2,7 @@
由 scripts/audit_nvs_access.mjs 扫描 ESP、共享 UI 和仓库 third_party 的 C/C++ 文件。只记录 NVS 函数、namespace/key/handle 实参和位置,不读取设备、不记录值/默认值/缓冲/分区名。不把最近的 nvs_open 自动推断为后续调用的 namespace;跨作用域句柄、宏和条件编译仍需人工核对。
扫描 1522 个源文件,找到 106 个候选调用,分布于 8 个文件。显式打开 15,键访问 45,其余为提交/关闭/清域。调用数不是独立配置数。
扫描 1527 个源文件,找到 106 个候选调用,分布于 8 个文件。显式打开 15,键访问 45,其余为提交/关闭/清域。调用数不是独立配置数。
这是源码入口清单,不是全部配置或硬件生效证明。系统 SDK、构建环境依赖、其他平台、函数指针/包装器实参以及复杂原始字符串不由此表自动覆盖。依赖库可单独调用 collectNvsAccesses 指定根目录审计;输出应单独记录其版本,不能写进与机器无关的仓库清单。
+15 -6
View File
@@ -19,10 +19,15 @@ On 2026-10-14 the user reserved the remaining Wio upload opportunity for final
release acceptance and became unavailable for manual device operations. Complete
remaining development and host-side verification before using it. Do not flash
intermediate or diagnostic candidates, or reset the device just to resume tests.
The last flashed source is `4be4f572`; retain the original identity, configuration
and pending publication fixture. An upload is permitted only when a release-ready
candidate actually needs the final physical acceptance run. Do not infer that an
available serial port is authorization to consume this reserved opportunity.
That reserved opportunity was subsequently consumed by combined TMAP firmware
`fbcdd8bb`, with application SHA-256
`6ab701588dca89739c4a796c39f366fc49f234f08b2bf449ed8d553460734ddc`.
The original author/RF identities and all 309 configuration fields were verified.
The later navigation/Map/POI candidate built successfully but remains unflashed;
Tracker title/card changes and the staged-draft SD contention fix are newer still.
Retain the original identities, configuration and publication fixtures. A further
upload requires a new device opportunity; an available application port is not
permission to reset or replace the running firmware.
The existing browser/native-directory track lifecycle has an opt-in physical
author mode. It requires an explicit application port, serial Python executable,
@@ -32,8 +37,12 @@ clients and retains the exact public confirmation checkpoint before writing.
Its default synthetic mode and synthetic API-bridge mode both pass locally with
two real native Reticulum directories, three signed versions, KML/attachment
reads and archived history after restart. The API-bridge check does not prove
physical USB signatures or device durability. The physical mode remains unused
until the reserved final acceptance run.
physical USB signatures or device durability. Physical mode has verified trail v1
signing, publication, directory proof and durable device confirmation. Subsequent
versions remain blocked by retained upload-slot capacity; the original checkpoint
must be preserved. Physical Geocaching v1 confirmation also passed. The same-record
update attempt returned a storage failure during draft save, so update/archive is
still unaccepted.
## Requested release deliverables
@@ -1124,6 +1124,24 @@ int main(int argc, char** argv)
};
require(staged(staged_id, 7) == rt::WebReadResult::AlreadySaved, "web file was not saved through the existing editor transaction");
require(permission(staged_id.data(), 1, router.identity.public_key.data()) == rt::WebReadResult::Ready, "web file save generation was not persisted");
// Ordinary SD contention during any selected-input operation must
// preserve the staged edit and resume at its last complete slice.
for (unsigned operation = 0; operation < 4; ++operation)
{
staged_id.fill(static_cast<uint8_t>(20 + operation));
test::file_busy_cycles = 1;
test::file_busy_remaining.fill(1);
test::file_busy_remaining[operation] = 0;
const auto hits = test::file_busy_hits[operation];
require(rt::queueWebDraftFile(staged_id.data(), static_cast<uint32_t>(edit_size), 0, edit_hash.data(), router.identity.public_key.data(), 7) == rt::WebReadResult::Pending,
"busy file test could not queue its edit");
until([&]
{ return test::file_busy_hits[operation] > hits; },
"staged input did not exercise SD contention");
test::file_busy_cycles = 0;
require(staged(staged_id, 7) == rt::WebReadResult::AlreadySaved, "SD contention rejected the staged web edit");
require(permission(staged_id.data(), 1, router.identity.public_key.data()) == rt::WebReadResult::Ready, "busy web edit was not persisted");
}
staged_id.fill(10);
test::files[rt::kWebDraftCandidatePath][0] ^= 1;
require(staged(staged_id, 7) == rt::WebReadResult::HashMismatch, "corrupted web candidate was committed");
@@ -2945,6 +2945,13 @@ static bool advanceWebDraftFile(Session& s)
if (!file.open(kWebDraftCandidatePath, "r") || file.size() != total || !file.seek(job.size) ||
file.read(job.bytes + job.size, count) != static_cast<int>(count))
{
// Map/other SD readers can own the filesystem briefly. Keep the edit
// and retry this same slice; only a real read failure rejects it.
if (file.read_busy())
{
next_step.store(millis() + 20);
return true;
}
failWebDraftSave(job, WebReadResult::Unavailable);
return true;
}
@@ -15,6 +15,8 @@ import {GeocachingHardwareExchange} from '../../tools/hostlink/geocaching_hardwa
const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1],hardwarePort=args.includes('--hardware-port')?option('--hardware-port'):null;
if(hardwarePort&&(!/^COM\d+$/.test(hardwarePort)||!args.includes('--serial-python')||!args.includes('--expected-author')||!/^[a-f0-9]{64}$/.test(option('--expected-author'))))throw Error('Hardware mode requires --hardware-port COMxx --serial-python <pyserial Python> --expected-author <fingerprint>');
const resumeDraftId=args.includes('--resume-draft')?option('--resume-draft'):null;
const resumeConfirmed=args.includes('--resume-confirmed-record');
if(resumeConfirmed&&(!resumeDraftId||!args.includes('--resume-confirmed-receipt')))throw Error('Confirmed resume requires the exact draft, signed v1 record and directory receipt');
if(resumeDraftId&&(!hardwarePort||!args.includes('--resume-name')||!/^Wio网页验收-[a-z0-9]+$/.test(option('--resume-name'))||!/^[a-f0-9]{32}$/.test(resumeDraftId)))throw Error('Resume requires explicit hardware, isolated acceptance name and ID');
const testName=hardwarePort?(resumeDraftId?option('--resume-name'):'Wio网页验收-'+Date.now().toString(36)):'真实网络网页藏宝点',updatedName=hardwarePort?testName+'-v2':'第二版网络藏宝点';
const python=process.env.TRAIL_MATE_TRACK_PYTHON??path.resolve('.codex-build/reticulum-directory/venv/'+(process.platform==='win32'?'Scripts/python.exe':'bin/python'));
@@ -26,6 +28,14 @@ async function freePort(){const socket=net.createServer();await new Promise(reso
const server=http.createServer(async(req,res)=>{try{const name=new URL(req.url,'http://localhost').pathname;if(name==='/trail-mate/geocaching/network.json'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({endpoint,discoverySeeds:seeds}));return;}if(!name.startsWith('/trail-mate/'))throw Error('prefix');const file=path.resolve(root,name.slice(12)+(name.endsWith('/')?'index.html':''));if(!file.startsWith(root+path.sep))throw Error('path');res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2','.ttf':'font/ttf'})[path.extname(file)]??'application/octet-stream');res.end(await fs.readFile(file));}catch{res.writeHead(404).end();}});await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));const origin='http://127.0.0.1:'+server.address().port;
const author=await Identity.generate(),publicKey=await author.getPublicKey(),nameHash=(await Identity.fullHash(new TextEncoder().encode('lxmf.delivery'))).slice(0,10),destination=await Identity.truncatedHash(Uint8Array.from([...nameHash,...author.identityHash])),info=[publicKey,author.identityHash,destination,7];
const drafts=new Map(),transfers=new Map(),signatures=new Map(),confirmations=new Map(),signedVersions=[],calls=[];let catalog=0,signCount=0,confirmCount=0;
let resumeBaseHash=null;
if(resumeConfirmed){
const signed=JSON.parse(await fs.readFile(option('--resume-confirmed-record'),'utf8')).map(value=>Uint8Array.from(value)),verified=await verifySigned(signed),record=verified.record;
assert.ok(equal(record[2],Uint8Array.from(Buffer.from(resumeDraftId,'hex'))));assert.equal(record[3],1);assert.equal(record[5],0);assert.equal(record[8],testName);assert.equal(record[6],310000000);assert.equal(record[7],1210000000);
assert.equal(Buffer.from(await crypto.subtle.digest('SHA-256',record[1])).toString('hex'),option('--expected-author'));
const receipt=Object.fromEntries(Object.entries(JSON.parse(await fs.readFile(option('--resume-confirmed-receipt'),'utf8'))).map(([key,value])=>[key,Uint8Array.from(value)]));await verifyPublicationReceipt([receipt.raw,receipt.signature],signed,receipt);
resumeBaseHash=verified.summary[2].slice();signedVersions.push(signed);
}
const countSignatures=()=>hardware?.signCount??signCount,countConfirmations=()=>hardware?.confirmCount??confirmCount;
const hex=bytes=>Buffer.from(bytes).toString('hex');
async function usbReply(raw){const frame=new FrameDecoder().push(Uint8Array.from(raw))[0];assert.ok(frame);if(frame.type===FRAME.HELLO)return [...encodeFrame(FRAME.HELLO_ACK,frame.sequence,new Uint8Array([1,0,0,2,0,4,0,0,6,...new TextEncoder().encode('T-Deck'),4,...new TextEncoder().encode('test')]))];const request=decodeManagement(frame.payload),body=request.body;calls.push(request.code);let response;
@@ -48,18 +58,18 @@ async function usbReply(raw){const frame=new FrameDecoder().push(Uint8Array.from
else assert.fail('Unexpected USB operation '+request.code);
return [...encodeFrame(FRAME.MANAGEMENT_RESPONSE,frame.sequence,encodeManagement(request.session,request.request,0,response))];}
try{
if(hardwarePort)hardware=new GeocachingHardwareExchange({port:hardwarePort,python:option('--serial-python'),fingerprint:option('--expected-author'),namePrefix:testName,resumeDraftId,calls,signedVersions,checkpointPath:path.join(work,'signature-recovery.json')});
if(hardwarePort)hardware=new GeocachingHardwareExchange({port:hardwarePort,python:option('--serial-python'),fingerprint:option('--expected-author'),namePrefix:testName,resumeDraftId,resumeBaseHash,calls,signedVersions,checkpointPath:path.join(work,'signature-recovery.json')});
await fs.rm(path.join(work,'result.json'),{force:true});
await fs.rm(path.join(state,'stop'),{force:true});const tcp=await freePort();service=start('directory',['tools/geocaching/browser_test_service.py','--state',state,'--port',String(tcp),'--empty','--startup-delay','0','--run-seconds','600']);const directory=await ready(service,'ready');seeds=[directory.discovery];bridge=start('bridge',['tools/geocaching/packet_bridge.py','--port','0','--tcp-port',String(tcp),'--origin',origin,'--run-seconds','600']);endpoint='ws://127.0.0.1:'+(await ready(bridge,'bridge_ready')).port;
browser=await chromium.launch({channel:process.env.TRAIL_MATE_TEST_BROWSER_CHANNEL,headless:true});const context=await browser.newContext({viewport:{width:1440,height:960}});await context.exposeFunction('deviceReply',hardware?raw=>hardware.exchange(raw):usbReply);await context.addInitScript(()=>{let input;const port={async open(){this.readable=new ReadableStream({start(controller){input=controller;}});this.writable=new WritableStream({async write(bytes){input.enqueue(Uint8Array.from(await window.deviceReply([...bytes])));}});},async setSignals(){},async close(){}};Object.defineProperty(navigator,'serial',{configurable:true,value:{requestPort:async()=>port}});});await context.route('https://tile.openstreetmap.org/**',route=>route.fulfill({contentType:'image/svg+xml',body:'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256" fill="#dceae4"/></svg>'}));
let page=await context.newPage();const errors=[];page.on('pageerror',error=>errors.push(String(error)));await page.goto(origin+'/trail-mate/geocaching/?lang=zh-Hans');await page.waitForFunction(()=>document.querySelector('#search')&&!document.querySelector('#search').disabled,{},{timeout:60000});await page.getByRole('button',{name:'草稿',exact:true}).click();await page.getByRole('button',{name:'连接设备',exact:true}).click();await page.getByText('USB 作者已验证',{exact:true}).waitFor();
if(resumeDraftId){await page.getByRole('button',{name:'刷新设备列表',exact:true}).click();await openTestDraft(testName);assert.ok(hardware.drafts.has(resumeDraftId));}
if(resumeDraftId){if(resumeConfirmed)await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await page.getByRole('button',{name:'刷新设备列表',exact:true}).click();await openTestDraft(testName);assert.ok(hardware.drafts.has(resumeDraftId));}
else{await page.getByRole('button',{name:'新建草稿',exact:true}).click();await page.getByLabel('名称',{exact:true}).fill(testName);await page.getByLabel('正文',{exact:true}).fill('网页中文正文 <script>window.__geoNetworkInjection=true</script>\n'.repeat(25));await page.getByLabel('提示',{exact:true}).fill('&< >'.repeat(80));await page.getByLabel('纬度',{exact:true}).fill('31');await page.getByLabel('经度',{exact:true}).fill('121');await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor({timeout:45000});}
async function publishAndSync(){await page.getByRole('button',{name:'刷新 IP 目录',exact:true}).click();await page.getByRole('button',{name:'检查并确认发布',exact:true}).click();assert.equal(await page.locator('dialog[open] script').count(),0);await page.locator('dialog[open]').getByRole('button',{name:'通过 IP 网络发布',exact:true}).click();await page.locator('.geo-publication-task').getByText('发布内容与目录签名证明已归档到设备。',{exact:true}).waitFor({timeout:60000});}
async function openTestDraft(name){for(let index=0;index<128;index++){await page.waitForFunction(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='刷新设备列表'&&!button.disabled),{},{timeout:45000});const row=page.locator('.geo-draft-row').filter({hasText:name});if(await row.count()){await row.click();await page.getByLabel('名称',{exact:true}).waitFor();return;}const more=page.getByRole('button',{name:'加载更多设备草稿',exact:true});if(!await more.count())throw Error('Isolated acceptance draft not present in current device catalogue');await more.click();}throw Error('Device catalogue acceptance search exceeded bounded page count');}
await publishAndSync();assert.equal(countSignatures(),1);assert.equal(countConfirmations(),1);await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await openTestDraft(testName);await page.getByLabel('名称',{exact:true}).fill(updatedName);await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor();await publishAndSync();await openTestDraft(updatedName);await page.getByLabel('状态',{exact:true}).selectOption('2');await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor();await publishAndSync();assert.equal(countSignatures(),3);assert.equal(countConfirmations(),3);assert.equal((await verifySigned(signedVersions[2])).record[5],2);if(hardware)await page.locator('.geo-publication-panel').screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/geocaching-wio-network-archive.png'});else await page.screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/geocaching-real-network-archive.png',fullPage:true});
if(!resumeConfirmed){await publishAndSync();assert.equal(countSignatures(),1);assert.equal(countConfirmations(),1);}await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await openTestDraft(testName);await page.getByLabel('名称',{exact:true}).fill(updatedName);await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor();await publishAndSync();await openTestDraft(updatedName);await page.getByLabel('状态',{exact:true}).selectOption('2');await page.getByRole('button',{name:'保存草稿到设备',exact:true}).click();await page.getByText('草稿已保存到设备。刷新列表可更新发布状态。',{exact:true}).waitFor();await publishAndSync();assert.equal(countSignatures(),resumeConfirmed?2:3);assert.equal(countConfirmations(),resumeConfirmed?2:3);assert.equal((await verifySigned(signedVersions[2])).record[5],2);if(hardware)await page.locator('.geo-publication-panel').screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/geocaching-wio-network-archive.png'});else await page.screenshot({path:'docs/locales/zh-Hans/web-outdoor-management/images/geocaching-real-network-archive.png',fullPage:true});
await context.grantPermissions(['clipboard-read','clipboard-write'],{origin});const expectedId=(await verifySigned(signedVersions[2])).cacheId;await page.getByRole('button',{name:'复制藏宝点 ID',exact:true}).click();assert.equal(await page.evaluate(()=>navigator.clipboard.readText()),expectedId);
const priorCalls=calls.length;for(const {id} of locales){await page.locator('.management-language select').selectOption(id);await page.locator('.geo-publication-task').getByText(geocachingIpPublicationText(id).confirmed,{exact:true}).waitFor();await page.locator('.geo-publication-task').getByText(geocachingDeviceSyncText(id).confirmed,{exact:true}).waitFor();await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth+1),false);assert.equal(calls.length,priorCalls);}await page.locator('.management-language select').selectOption('zh-Hans');await page.setViewportSize({width:1440,height:960});
const native=spawnSync(python,['tools/geocaching/verify_browser_publication.py','--database',path.join(state,'service','directory.sqlite')],{input:JSON.stringify(signedVersions.map(item=>item.map(part=>[...part]))),windowsHide:true,maxBuffer:1048576});assert.equal(native.status,0,String(native.stderr));const nativeCheck=JSON.parse(native.stdout);assert.equal(nativeCheck.transportDiffersFromAuthor,true);
await page.reload();await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await page.getByRole('button',{name:'连接设备',exact:true}).click();await page.getByText('USB 作者已验证',{exact:true}).waitFor();await page.locator('.geo-publication-task').getByText('发布内容与目录签名证明已归档到设备。',{exact:true}).waitFor();assert.equal(countSignatures(),3);assert.equal(countConfirmations(),3);assert.equal(await page.evaluate(()=>window.__geoNetworkInjection),undefined);assert.ok(signedVersions.every(item=>item[0].length>512));assert.ok(!calls.some(code=>[7,16,17,20,21,67,68,70].includes(code)));assert.deepEqual(errors,[]);await fs.writeFile(path.join(work,'result.json'),JSON.stringify({passed:true,actualPage:true,actualWorker:true,nativeNetwork:true,nativeWebSerial:false,usb:hardware?'physical Wio L2 via restricted browser serial API test bridge':'synthetic independent identity with real signatures',author:hardware?{fingerprint:hardware.author.fingerprint,generation:hardware.author.generation}:undefined,cacheId:expectedId,created:true,updated:true,archived:true,directoryProof:true,deviceConfirmation:true,refreshRecovery:true,signatures:countSignatures(),confirmations:countConfirmations(),rfRequests:0,configWrites:0,keyExports:0,locales:locales.length,mobileWidth:390,clipboardId:true,resourceSizedContent:true,nativeCheck},null,2));console.log('Actual Geocaching page + Worker + native network: create/update/archive/proof/device confirmation and refresh passed. USB: '+(hardware?'physical Wio L2':'synthetic'));
await page.reload();await page.getByRole('button',{name:'我的藏宝点',exact:true}).click();await page.getByRole('button',{name:'连接设备',exact:true}).click();await page.getByText('USB 作者已验证',{exact:true}).waitFor();await page.locator('.geo-publication-task').getByText('发布内容与目录签名证明已归档到设备。',{exact:true}).waitFor();assert.equal(countSignatures(),resumeConfirmed?2:3);assert.equal(countConfirmations(),resumeConfirmed?2:3);assert.equal(await page.evaluate(()=>window.__geoNetworkInjection),undefined);assert.ok(signedVersions.every(item=>item[0].length>512));assert.ok(!calls.some(code=>[7,16,17,20,21,67,68,70].includes(code)));assert.deepEqual(errors,[]);await fs.writeFile(path.join(work,'result.json'),JSON.stringify({passed:true,actualPage:true,actualWorker:true,nativeNetwork:true,nativeWebSerial:false,usb:hardware?'physical Wio L2 via restricted browser serial API test bridge':'synthetic independent identity with real signatures',author:hardware?{fingerprint:hardware.author.fingerprint,generation:hardware.author.generation}:undefined,cacheId:expectedId,created:true,resumedVerifiedV1:resumeConfirmed,updated:true,archived:true,directoryProof:true,deviceConfirmation:true,refreshRecovery:true,signatures:countSignatures(),confirmations:countConfirmations(),rfRequests:0,configWrites:0,keyExports:0,locales:locales.length,mobileWidth:390,clipboardId:true,resourceSizedContent:true,nativeCheck},null,2));console.log('Actual Geocaching page + Worker + native network: create/update/archive/proof/device confirmation and refresh passed. USB: '+(hardware?'physical Wio L2':'synthetic'));
}finally{try{if(browser){const pages=browser.contexts().flatMap(context=>context.pages());for(const page of pages){await fs.writeFile(path.join(work,'page-state.json'),JSON.stringify({signatures:countSignatures(),confirmations:countConfirmations(),operations:calls,responses:hardware?.responses,status:await page.locator('.geo-maintenance-list p').first().textContent().catch(()=>null),publication:await page.locator('.geo-publication-panel').textContent().catch(()=>null)},null,2));if(hardware)await page.locator('.geo-publication-panel').screenshot({path:path.join(work,'page.png')}).catch(()=>{});else await page.screenshot({path:path.join(work,'page.png'),fullPage:true}).catch(()=>{});}}}finally{await browser?.close();try{await hardware?.close();}finally{if(service&&service.child.exitCode===null){await fs.writeFile(path.join(state,'stop'),'stop');await Promise.race([service.done,sleep(15000)]);}for(const entry of children){if(entry.child.exitCode===null)entry.child.kill();await entry.done;await fs.writeFile(path.join(work,entry.label+'.log'),entry.output);}await new Promise(resolve=>server.close(resolve));}}}
@@ -6,10 +6,12 @@ import path from 'node:path';
import {FrameDecoder,FRAME} from '../../site/device/src/hostlink.js';import {decodeManagement} from '../../site/device/src/management-protocol.js';import {decode,verifySigned,equal} from '../../site/geocaching/src/protocol.js';import {validateAuthorInfo} from '../../site/device/src/author-identity.js';
const allowed=new Set([0,1,2,3,32,33,34,35,36,37,38,64,65,66,71,72,73,74,75,76,77]),hex=value=>Buffer.from(value).toString('hex');
export class GeocachingHardwareExchange{
constructor({port,python,fingerprint,namePrefix,resumeDraftId=null,calls=[],signedVersions=[],checkpointPath=null}){
constructor({port,python,fingerprint,namePrefix,resumeDraftId=null,resumeBaseHash=null,calls=[],signedVersions=[],checkpointPath=null}){
if(!/^COM\d+$/.test(port)||!/^[a-f0-9]{64}$/.test(fingerprint)||!namePrefix)throw Error('Explicit port, expected author and isolated test-name prefix required');
this.fingerprint=fingerprint;this.namePrefix=namePrefix;this.calls=calls;this.signedVersions=signedVersions;this.signatures=new Set();this.confirmations=new Set();this.confirmed=new Set();this.drafts=new Set();this.transfers=new Map();this.outputs=new Map();this.completed=new Set();this.pending=null;this.queue=Promise.resolve();this.broken=null;
if(resumeDraftId!==null&&(!/^[a-f0-9]{32}$/.test(resumeDraftId)||!/^Wio网页验收-[a-z0-9]+$/.test(namePrefix)))throw Error('Resume requires an explicit isolated acceptance ID and name');this.resumeDraftId=resumeDraftId;
if(resumeBaseHash!==null)assert.ok(resumeDraftId&&resumeBaseHash instanceof Uint8Array&&resumeBaseHash.length===32,'Confirmed resume requires an exact signed base');
this.resumeBaseHash=resumeBaseHash?.slice()??null;
this.checkpointPath=checkpointPath;
this.child=spawn(python,[fileURLToPath(new URL('./serial_transport.py',import.meta.url)),port],{windowsHide:true,stdio:['pipe','pipe','pipe']});this.stderr='';this.child.stderr.on('data',bytes=>{this.stderr=(this.stderr+bytes).slice(-4096);});
this.done=new Promise((resolve,reject)=>{this.child.on('error',error=>{this.broken=error;this.pending?.reject(error);this.pending=null;reject(error);});this.child.on('exit',code=>{if(this.pending){this.pending.reject(Error('Hardware serial process exited: '+code));this.pending=null;}resolve(code);});});this.done.catch(()=>{});
@@ -61,7 +63,7 @@ export class GeocachingHardwareExchange{
if(code===33){const row=this.transfers.get(hex(body[0]));row.content.set(body[2],body[1]);row.offset=Math.max(row.offset,body[1]+body[2].length);}
if(code===35){const row=this.transfers.get(hex(body[0]));assert.equal(row.offset,row.total);assert.ok(equal(new Uint8Array(await crypto.subtle.digest('SHA-256',row.content)),row.hash));row.sealed=true;}
if(code===66)this.drafts.add(hex(body[1]));
if(code===64&&value[3]&&hex(value[3][0])===this.resumeDraftId&&!this.drafts.has(this.resumeDraftId)){const row=value[3];assert.equal(row[3],this.namePrefix);assert.equal(row[5],310000000);assert.equal(row[6],1210000000);assert.equal(row[4],0);assert.equal(row[8][0],0);assert.ok(row[2]===null||equal(row[2],this.author.publicKey));this.drafts.add(this.resumeDraftId);}
if(code===64&&value[3]&&hex(value[3][0])===this.resumeDraftId&&!this.drafts.has(this.resumeDraftId)){const row=value[3];assert.equal(row[3],this.namePrefix);assert.equal(row[5],310000000);assert.equal(row[6],1210000000);assert.equal(row[4],0);assert.equal(row[8][0],this.resumeBaseHash?1:0);if(this.resumeBaseHash){assert.equal(row[8][1],1);assert.equal(row[8][2]&7,0);assert.ok(equal(row[7],this.resumeBaseHash));}assert.ok(row[2]===null||equal(row[2],this.author.publicKey));this.drafts.add(this.resumeDraftId);}
if(code===71)this.signatures.add(hex(body[4]));
if(code===75)this.confirmations.add(hex(body[0]));
if([75,76].includes(code)&&value[3]===1&&value[7]===1)this.confirmed.add(hex(body[0]));