import { readFile, writeFile, rename } from 'node:fs/promises'; import { join } from 'node:path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execute=promisify(execFile); export async function macNotify(message) { if(process.platform!=='darwin')throw Error('notification_platform_unavailable'); await execute('/usr/bin/osascript',['-e','on run argv\ndisplay notification (item 1 of argv) with title "小树夜间同步"\nend run',message],{timeout:10000}); } // Only fixed text and aggregate counts are shown; raw errors and credentials never enter notifications. export async function syncAlert(report,root,notify=macNotify,now=Date.now()) { const path=join(root,'direct-alert-state.json');let prior={};try{prior=JSON.parse(await readFile(path,'utf8'));}catch(e){if(e.code!=='ENOENT')throw e;} const healthy=['completed','completed-with-preserved-conflicts','already_completed'].includes(report.status); if(!healthy&&report.status!=='blocked')return; const count=Number(report.conflicts??report.previous?.conflicts??0),growth=healthy&&Number.isFinite(prior.conflicts)&&count>=prior.conflicts+Math.max(100,Math.ceil(prior.conflicts*.25)); let kind=!healthy?'failure':prior.failed?'recovered':growth?'conflicts':''; const key=kind==='failure'?'failure':kind==='conflicts'?'conflicts:'+count:kind; const due=kind&&(prior.lastKey!==key||now-Number(prior.notifiedAt||0)>=24*3600000); const next={...prior,failed:!healthy,...(healthy?{conflicts:count,lastSuccessAt:now}:{})}; if(due){await notify(kind==='failure'?'今晚的数据同步未完成,系统会重试。请查看同步报告并检查登录凭据。':kind==='recovered'?'数据同步已恢复,请查看本次报告。':'同步保留差异明显增加('+count+' 条),请在后台核对。');next.lastKey=key;next.notifiedAt=now;} await writeFile(path+'.tmp',JSON.stringify(next),{mode:0o600});await rename(path+'.tmp',path); return {notified:Boolean(due),kind}; }