diff --git a/README.md b/README.md index 45e7b7a..013062b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向 Windows 和 macOS 的极简桌面环境工具。以 [claude-switcher](https://github.com/YOURSJEYO/claude-switcher) 为功能底座,保留原版维护能力,增加独立检测、事务式账号管理、代理诊断和历史对比。 -当前源码 **0.24.0 Alpha** 将首页简化为“地区与时区 → 一键检测 → 问题和修复 → 持久化守护”。问题逐项可见,缺失的必需证据单独说明;时区、地区格式和 Code 配置合并审核,确认后修复并自动复检。地区预设扩充至 **247 个国家和地区、417 个城市/时区**。账号、全部检查、工具和历史统一放在“更多功能”。参见 [新版操作流程](docs/SIMPLE_WORKFLOW.md) 和 [完整功能对照](docs/FULL_PARITY_AUDIT.md)。公开安装包以 Releases 为准。 +当前源码 **0.24.1 Alpha** 按“地区与时区 → 一键检测 → 问题和修复 → 持久化守护”运行。复检后自动更新修复方案,保留重新生成入口和上次执行结果;方案过期、权限拒绝或检测失败会说明原因与下一步。时区、地区格式和 Code 配置合并审核,确认后修复并复检;守护状态显示具体恢复结果。地区预设覆盖 **247 个国家和地区、417 个城市/时区**。账号、全部检查、工具和历史统一放在“更多功能”。参见 [操作流程](docs/SIMPLE_WORKFLOW.md) 和 [完整功能对照](docs/FULL_PARITY_AUDIT.md)。公开安装包以 Releases 为准。 **0.23.0 已加入**可恢复的旧软件清理、临时浏览器独立清理进程与重启恢复,以及 48 小时不同出口集合统计。清理支持单项 / 全选,先预览、再确认,保留账号与凭据。参见 [旧软件清理](docs/LEGACY_SOFTWARE.md) 和 [临时浏览器](docs/BROWSER_SESSIONS.md)。 diff --git a/docs/SIMPLE_WORKFLOW.md b/docs/SIMPLE_WORKFLOW.md index aa5f14a..48a3997 100644 --- a/docs/SIMPLE_WORKFLOW.md +++ b/docs/SIMPLE_WORKFLOW.md @@ -4,8 +4,8 @@ 1. **地区与时区**:填写实际所在地;读取当前出口 IP,建议节点地区及对应城市。选择城市会匹配 Windows / macOS 时区、地区格式和语言目标。手动选择和已保存目标不会被迟到的查询结果覆盖。用途与连接方式按需展开。 2. **一键检测**:完成目标确认后开始。结果说明必需项通过、不合格和待完成的数量;所有问题标题、实际观测与处理方式直接可见。需要权限、缺少证据和本版不支持的检查分别说明,不计为通过。 -3. **修复方案**:先列出具体字段、原值与目标或可执行操作。时区、地区格式与 Code 配置合并审核,确认后才执行,随后自动复检。保留备份和恢复入口。需要先启动客户端的操作仍分阶段复检;无法自动处理的项目标红并给出步骤。 -4. **持久化守护**:选择周期以及需要保持的时区、地区格式和代理;额外客户端与 Code 选项折叠。预览具体基线并确认启用,同页可停用。后台仅执行已批准的范围并保存结果,未获得权限的修改保留原因。 +3. **修复方案**:先列出具体字段、原值与目标或可执行操作。时区、地区格式与 Code 配置合并审核,确认后才执行,随后自动复检。复检使旧方案失效,再按当前问题生成新方案;重新生成入口始终保留。方案生成失败可重试,检测失败时说明需要先重新检测。上次执行结果、失败原因、备份和恢复入口独立保留;部分失败不会重复自动执行,需核对恢复记录。需要先启动客户端的操作仍分阶段复检;无法自动处理的项目标红并给出步骤。 +4. **持久化守护**:选择周期以及需要保持的时区、地区格式和代理;额外客户端与 Code 选项折叠。预览具体基线并确认启用,同页可停用。后台仅执行已批准的范围并保存结果,未获得权限的修改保留原因。首页显示最近完成时间和各项基线的实际恢复结果;失败、过期或暂停以红色提示,完整日志在“更多功能 → 守护与高级修复”。 顶部“更多功能”提供全部检查(选择 / 全选 / 批量处理)、账号资料、工具与备份、守护与高级修复、历史、设置与更新、环境全览。详细测量、帮助和 Claude 启动入口放在首页末尾,不妨碍主流程。 @@ -19,6 +19,6 @@ ## 验收范围 -`scripts.simple_workflow_smoke` 使用一次性配置、模拟 Windows / macOS 系统和替代调度器,检查首次提示、地区选择、问题显示、修复审核、自动复检、权限指引、守护启停及全部导航。1024、1280、1440 桌面宽度检查溢出。Code 测试另覆盖损坏 JSON 与证书设置一次修复、已有字段保留和未选择范围拒绝写入。 +`scripts.simple_workflow_smoke` 使用一次性配置、模拟 Windows / macOS 系统和替代调度器,检查首次提示、地区选择、问题内复检生成新方案、过期方案拒绝写入、生成失败重试、复检失败恢复、权限拒绝后的手动恢复、守护启停及全部导航。模拟时区与地区格式漂移后,调用真实守护逻辑,核对恢复读回、日志和首页结果。1024、1280、1440 桌面宽度检查溢出。Code 测试另覆盖损坏 JSON 与证书设置一次修复、已有字段保留和未选择范围拒绝写入。 以上为隔离验收,不修改测试机器的真实时区、代理、账号或后台任务,也不代表 Claude 实际认证已通过。 diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 7f00703..a021d9c 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -8,7 +8,7 @@ 检测后,所有问题标题、观测和处理方式逐项可见。展开一项可查看原因与步骤;缺少必需证据也必须有明确入口。不能自动修复的项目用红色文字说明。技术记录、已通过的观测与帮助默认折叠。 -修复区先列具体改动,只保留一个确认修复并复检按钮。持久化守护紧随其后:选择间隔与需保持的配置,预览、确认、启停都在同页。后台权限或修复失败必须显示原因,不能仅凭写入成功就显示环境合格。 +修复区先列具体改动,只保留一个确认修复并复检按钮,并保留“重新生成处理方案”的次要入口。复检清除旧方案、刷新剩余问题;上次执行结果独立保留。没有自动修改或没有取得本轮报告时,入口置灰并说明原因。持久化守护紧随其后:选择间隔与需保持的配置,预览、确认、启停都在同页。后台权限或修复失败必须显示原因,不能仅凭写入成功就显示环境合格。 ```text EnvScope 版本 检测与修复 更多功能 diff --git a/docs/images/desktop.png b/docs/images/desktop.png index edf9cd4..5c92f13 100644 Binary files a/docs/images/desktop.png and b/docs/images/desktop.png differ diff --git a/envscope/__init__.py b/envscope/__init__.py index d05b805..e54e6a8 100644 --- a/envscope/__init__.py +++ b/envscope/__init__.py @@ -1,4 +1,4 @@ """EnvScope: evidence-first environment diagnostics.""" -__version__ = "0.24.0" +__version__ = "0.24.1" __release__ = "v" + __version__ + "-alpha.1" diff --git a/envscope/web/app.mjs b/envscope/web/app.mjs index 96b7ea1..33b2c06 100644 --- a/envscope/web/app.mjs +++ b/envscope/web/app.mjs @@ -89,7 +89,7 @@ function renderDesktop(report,source='current') { document.dispatchEvent(new CustomEvent('envscope-report',{detail:{report,source}})); byId("session-state").textContent="完成于 "+new Date(report.created_at).toLocaleTimeString(); } -const diagnosisUI=initDiagnosis({sessionPromise,retest:()=>runAudit({phase:'recheck'})}); +const diagnosisUI=initDiagnosis({sessionPromise,retest:options=>runAudit({phase:'recheck',...options})}); const targetUI=initTargetEnvironment({sessionPromise,run:diagnosisUI.run,retest:()=>runAudit({oneStop:true}),isBusy:diagnosisUI.isBusy}); initWatchdogForms(diagnosisUI.run); initInspection({run:diagnosisUI.run,operate:diagnosisUI.operate,isBusy:diagnosisUI.isBusy,retest:checks=>runAudit({checks})}); @@ -110,7 +110,7 @@ async function nativeAudit(session,snapshot,checks=null) { } finally {clearTimeout(timer);} } -async function runAudit({oneStop=false,phase='check',checks=null}={}){ +async function runAudit({oneStop=true,phase='check',checks=null}={}){ if(!await targetUI.ensure())return; byId('audit-results').hidden=false; setFlowStage(phase==='recheck'?'repair':'check',phase==='recheck'?'正在重新检测配置与连接,写入成功不代替复检结果。':'正在检查连接和环境设置,尚未写入任何配置。'); diff --git a/envscope/web/diagnosis.mjs b/envscope/web/diagnosis.mjs index e054821..2a4830e 100644 --- a/envscope/web/diagnosis.mjs +++ b/envscope/web/diagnosis.mjs @@ -24,11 +24,24 @@ export function describeProxy(item){ } export function initDiagnosis({sessionPromise,retest}) { - let report=null,busy=false,proxies=[],executionFailed=false,activeCheck=null; + let report=null,busy=false,proxies=[],executionFailed=false,activeCheck=null,revision=0; const output=$('solution-output'); function button(text,fn){const b=el('button',text);b.type='button';b.addEventListener('click',fn);return b;} function link(text,url){const a=el('a',text,'link');a.href=url;a.target='_blank';a.rel='noopener noreferrer';return a;} - function setBusy(value){busy=value;for(const id of ['start','target-edit','target-confirm','target-cancel','prepare-solution','quick-proxy','flow-upkeep-prepare','flow-upkeep-stop','flow-upkeep-interval','flow-upkeep-restore','enhance-cli','workflow-proxy','workflow-proxy-url','usage'])$(id).disabled=value;for(const node of document.querySelectorAll('#desktop-preferences button'))node.disabled=value;document.documentElement.dataset.operationBusy=value?'true':'false';document.dispatchEvent(new CustomEvent('envscope-operation-state',{detail:{busy:value}}));} + function updateRepairEntry(){ + const blocked=busy||$('start').disabled||document.documentElement.dataset.operationBusy==='true'; + const checks=report&&Array.isArray(report.selection?.selected)?workflowChecks(report,$('enhance-cli').checked):null; + const available=!!report&&(checks?checks.length>0:!!report.diagnosis?.problems.length); + $('prepare-solution').hidden=false; + $('prepare-solution').disabled=blocked||!available; + $('prepare-solution').textContent=!report?'检测后生成修复方案':!available?'本轮无需自动修改':$('solution-plan').children.length?'重新生成处理方案':'生成修复方案'; + for(const node of document.querySelectorAll('#solution-plan button,#solution-backups button'))node.disabled=blocked; + } + function clearPlan(){revision++;$('solution-plan').replaceChildren();updateRepairEntry();} + const busyObserver=new MutationObserver(updateRepairEntry); + busyObserver.observe($('start'),{attributes:true,attributeFilter:['disabled']}); + busyObserver.observe(document.documentElement,{attributes:true,attributeFilter:['data-operation-busy']}); + function setBusy(value){busy=value;for(const id of ['start','target-edit','target-confirm','target-cancel','quick-proxy','flow-upkeep-prepare','flow-upkeep-stop','flow-upkeep-interval','flow-upkeep-restore','enhance-cli','workflow-proxy','workflow-proxy-url','usage'])$(id).disabled=value;for(const node of document.querySelectorAll('#desktop-preferences button'))node.disabled=value;document.documentElement.dataset.operationBusy=value?'true':'false';updateRepairEntry();document.dispatchEvent(new CustomEvent('envscope-operation-state',{detail:{busy:value}}));} async function run(action,params={},confirmed=false){ const session=await sessionPromise;if(!session)throw new Error('请打开本地桌面程序,再使用处理功能。'); const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),240000); @@ -38,7 +51,7 @@ export function initDiagnosis({sessionPromise,retest}) { }finally{clearTimeout(timer);} } async function action(fn,errorOutput=output){ - if(busy||$('start').disabled)return; + if(busy||$('start').disabled||document.documentElement.dataset.operationBusy==='true')return; setBusy(true); try{await fn();}catch(error){errorOutput.textContent=error.name==='AbortError'?'等待超时。操作可能仍在进行;查看实际状态后再复检。':error.message;} finally{setBusy(false);} @@ -88,31 +101,58 @@ export function initDiagnosis({sessionPromise,retest}) { const b=button('撤销 '+record.label,()=>action(async()=>{ output.textContent='正在校验备份和当前文件…'; const value=await run('solution-undo',{backup_id:record.id},true);output.textContent=value.reason||'撤销尚未完成。'; - if(value.state==='completed'){b.disabled=true;await retest();} + $('solution-result').textContent=value.reason||'撤销尚未完成,请核对恢复记录。'; + $('solution-result').classList.toggle('inspection-error',value.state!=='completed'); + executionFailed=value.state!=='completed'; + if(value.state==='completed'){b.remove();await recheckAndPrepare();} })); $('solution-backups').append(b); } } + async function recheckAndPrepare(checkId=null,workflow=true){ + // We already own the operation lock. The top-level action wrapper would + // intentionally reject a nested preview, so prepare under this same lock. + await retest({oneStop:false}); + if(report)await prepare(checkId,workflow); + } async function applyPlan(value,options=null){ - if(options!==null&&options!==optionsKey()){output.textContent='环境选项已经变化,请重新检测并审核新的方案。';$('solution-plan').replaceChildren();return;} + if(options!==null&&options!==optionsKey()){output.textContent='环境选项已经变化,请重新检测并审核新的方案。';clearPlan();return;} setFlowStage('repair','正在执行已确认的改动;先备份配置,完成后自动复检。'); - $('solution-plan').replaceChildren();output.textContent='正在执行已确认的方案。配置写入完成后还需复检。'; - const result=await run(value.engine==='inspection'?'inspection-apply':'solution-apply',{plan_id:value.plan_id},true); + clearPlan();output.textContent='正在执行已确认的方案。配置写入完成后还需复检。'; + const receipt=$('solution-result');receipt.classList.remove('inspection-error');receipt.textContent='已提交确认的修复,等待执行结果。'; + let result; + try{result=await run(value.engine==='inspection'?'inspection-apply':'solution-apply',{plan_id:value.plan_id},true);} + catch(error){ + executionFailed=true;report=null;updateRepairEntry();receipt.classList.add('inspection-error'); + receipt.textContent='修复结果尚未确认。请先重新检测,再查看恢复记录;不会自动重复执行。';throw error; + } executionFailed=['partial','failed','blocked'].includes(result.state); + receipt.classList.toggle('inspection-error',executionFailed||result.state==='retest_required'); + const state={completed:'修复操作已完成',executed:'修复操作已执行',partial:'修复尚未完成',failed:'修复失败',blocked:'修复被阻止',retest_required:'方案已失效,未继续执行',waiting_install:'等待安装完成'}[result.state]||'修复操作已返回'; + const steps=[...new Set((result.results||[result]).flatMap(r=>(r.steps||[]).filter(s=>s.reason&&!['completed','verified','started'].includes(s.state)).map(s=>s.reason)))]; + receipt.textContent=[state+'。',result.reason,...steps].filter(Boolean).join('\n'); backups(result.backups);output.textContent=result.reason||'操作已返回,正在检查实际结果。'; - if(result.results?.some(r=>r.recovery)||result.recovery)$('solution-backups').append(button('查看地区修改与恢复记录',()=>document.dispatchEvent(new CustomEvent('envscope-region-configure')))); + if((result.results?.some(r=>r.recovery)||result.recovery)&&!$('solution-region-recovery')){ + const recovery=button('查看地区修改与恢复记录',()=>document.dispatchEvent(new CustomEvent('envscope-region-configure'))); + recovery.id='solution-region-recovery';$('solution-backups').append(recovery); + } if(result.state==='waiting_install'){ - $('solution-plan').append(button('安装完成,继续复检',()=>action(async()=>{await retest();}))); - }else if(result.needs_retest){ const nextCheck=activeCheck; - await retest(); - if(result.next_review&&report)await prepare(nextCheck,options!==null); + $('solution-plan').append(button('安装完成,继续复检',()=>action(async()=>{ + receipt.textContent='正在复检安装后的环境,尚未确认安装结果。'; + try{await recheckAndPrepare(nextCheck,options!==null);} + finally{receipt.textContent=report?'安装后复检已完成,请按当前结果继续处理;未自动执行新的修改。':'安装后复检未完成,请重新检测;尚未确认安装结果。';} + }))); + }else if(result.needs_retest){ + await recheckAndPrepare(activeCheck,options!==null); } } async function prepare(checkId=null,workflow=false){ + if(!report){output.textContent='请先重新检测,取得当前结果后生成方案。';return;} + clearPlan();const requestRevision=revision,requestReport=report.report_id,requestOptions=optionsKey(); activeCheck=checkId;$('solution-tools').hidden=false; $('solution-tools').dataset.workflow=String(workflow); - if(workflow){$('prepare-solution').hidden=true;$('quick-proxy').hidden=true;$('quick-proxy-select').hidden=true;} + if(workflow){$('quick-proxy').hidden=true;$('quick-proxy-select').hidden=true;} if(workflow)$('session-state').textContent='正在验证连接与生成方案'; setFlowStage('review','正在合并配置与修复方案,测试候选连接;尚未写入。'); output.textContent='正在核对可自动处理的配置和代理,生成具体方案…'; @@ -126,7 +166,8 @@ export function initDiagnosis({sessionPromise,retest}) { if(integrated)params.check_ids=checks; if(workflow){params.enhance_cli=$('enhance-cli').checked&&(!integrated||checks.includes('extra.cli_env'));const selected=$('workflow-proxy').value;if(params.enhance_cli&&selected!=='auto')params.proxy=selected==='manual'?$('workflow-proxy-url').value.trim():selected;} let value; - try{value=await run(integrated?'inspection-plan':'solution-plan',params);if(integrated)value.engine='inspection';}catch(error){if(workflow)$('session-state').textContent='方案未完成,请重新检测';setFlowStage('review','方案未完成,尚未写入配置。请重试或按具体问题处理。');throw error;} + try{value=await run(integrated?'inspection-plan':'solution-plan',params);if(integrated)value.engine='inspection';}catch(error){if(workflow)$('session-state').textContent='方案未完成,可重新生成';setFlowStage('review','方案未完成,尚未写入配置。点击生成修复方案重试。');throw error;} + if(revision!==requestRevision||report?.report_id!==requestReport||workflow&&optionsKey()!==requestOptions){output.textContent='检测结果或环境选项已变化,请重新生成方案。';return;} $('solution-plan').replaceChildren(); if(workflow)$('session-state').textContent=value.state==='planned'?'方案已就绪,等待你确认':'本轮方案核对已结束'; output.textContent=[value.reason,...value.manual||[]].filter(Boolean).join('\n'); @@ -145,11 +186,16 @@ export function initDiagnosis({sessionPromise,retest}) { }else{ setFlowStage('review',value.state==='retest_required'?'检测已过期,请重新一键检测。':'检测和方案核对已完成;本次没有执行配置写入。'); } + updateRepairEntry(); } function renderInlineUpkeep(value){ if(!value)return; const states={disabled:'尚未启用',waiting:'已启用,等待首次巡检',active:'最近一轮已完成',attention:'巡检完成,配置保持需核对',stale:'巡检已超期',failed:'最近一轮未完成',schedule_unverified:'系统任务状态未确认',invalid:'维护记录无法核实'}; $('flow-upkeep-status').textContent=[states[value.state]||'状态待核实',value.completed_at?'完成于 '+new Date(value.completed_at).toLocaleString():'',value.enabled?`每 ${value.interval} 分钟复检`:'',['attention','failed','stale','invalid','schedule_unverified'].includes(value.state)?value.reason:''].filter(Boolean).join('。'); + const notes=Object.entries(value.watchdog||{}).map(([key,item])=>`${({timezone:'时区',locale:'地区格式',proxy:'代理'})[key]||key}:${item.note||'等待巡检结果'}`); + if(value.restore_note)notes.push('Code 配置:'+value.restore_note); + if(notes.length)$('flow-upkeep-status').textContent+='。'+notes.join(';'); + $('flow-upkeep-status').classList.toggle('inspection-error',['attention','failed','stale','invalid','schedule_unverified'].includes(value.state)); $('flow-upkeep-stop').hidden=!value.enabled&&!value.scheduler?.present; } $('flow-upkeep-prepare').addEventListener('click',()=>action(async()=>{ @@ -170,13 +216,14 @@ export function initDiagnosis({sessionPromise,retest}) { })); document.addEventListener('envscope-catalog',event=>{renderInlineUpkeep(event.detail.maintenance);renderProxyOptions(event.detail.proxies);}); document.addEventListener('envscope-maintenance-status',event=>renderInlineUpkeep(event.detail)); - document.addEventListener('envscope-audit-start',()=>{report=null;$('flow-upkeep').hidden=true;$('flow-upkeep-plan').replaceChildren();$('solution-plan').replaceChildren();}); - document.addEventListener('envscope-target-changed',()=>{report=null;$('flow-upkeep').hidden=true;$('flow-upkeep-plan').replaceChildren();$('solution-plan').replaceChildren();$('solution-tools').hidden=true;$('try-claude').hidden=true;}); - document.addEventListener('envscope-target-editing',()=>{$('solution-plan').replaceChildren();$('flow-upkeep-plan').replaceChildren();}); + document.addEventListener('envscope-audit-start',()=>{report=null;clearPlan();output.textContent='正在重新检测,旧方案已作废。';$('flow-upkeep').hidden=true;$('flow-upkeep-plan').replaceChildren();}); + document.addEventListener('envscope-audit-end',event=>{if(!event.detail.has_report){$('solution-tools').hidden=false;output.textContent='本轮检测未完成,暂不能修复。点击上方“重新检测”补齐当前结果。';}updateRepairEntry();}); + document.addEventListener('envscope-target-changed',()=>{report=null;clearPlan();$('solution-result').replaceChildren();executionFailed=false;activeCheck=null;$('flow-upkeep').hidden=true;$('flow-upkeep-plan').replaceChildren();$('solution-tools').hidden=true;$('try-claude').hidden=true;}); + document.addEventListener('envscope-target-editing',()=>{clearPlan();output.textContent='旧方案已清除,返回后可重新生成。';$('flow-upkeep-plan').replaceChildren();}); for(const id of ['usage','enhance-cli','workflow-proxy','workflow-proxy-url'])$(id).addEventListener('change',()=>{ updateOptionsLabel(); $('workflow-proxy-manual').hidden=$('workflow-proxy').value!=='manual'; - if($('solution-plan').children.length){$('solution-plan').replaceChildren();output.textContent='环境选项已变化,请重新检测并生成方案。';} + clearPlan();output.textContent='环境选项已变化,请重新生成方案;需要新证据的项目请重新检测。'; }); function renderProblem(problem){ const row=el('details',undefined,'problem');row.dataset.problem=problem.id;row.dataset.manual=String(!problem.automatic); @@ -198,7 +245,7 @@ export function initDiagnosis({sessionPromise,retest}) { else if(['settings','accounts'].includes(problem.action))controls.append(button(problem.action==='settings'?'调整比较或启动设置':'打开账号管理',()=>document.querySelector(`.tab[data-page="${problem.action}"]`)?.click())); else if(links[problem.action])controls.append(link(...links[problem.action])); else if(problem.action==='region-support')controls.append(button('核对实际使用地区',()=>document.dispatchEvent(new CustomEvent('envscope-target-review'))),link('查看 Claude 官方地区名单','https://support.claude.com/en/articles/8461763-where-can-i-access-claude'),link('查看官方 API 地区名单','https://platform.claude.com/docs/en/api/supported-regions')); - if(problem.automatic)controls.append(button('查看修复方案',()=>{$('solution-tools').scrollIntoView({block:'start'});})); + if(problem.automatic)controls.append(button('查看修复方案',()=>action(async()=>{if(!$('apply-solution'))await prepare(null,true);$('solution-tools').scrollIntoView({block:'start'});}))); if(['baseline.country','target.region'].includes(problem.check)&&problem.action!=='region-support')controls.append(button('核对地区与节点',()=>document.dispatchEvent(new CustomEvent('envscope-region-configure')))); controls.append(button('重新检测',()=>{if(!busy)void retest();}));row.append(controls);return row; } @@ -238,9 +285,9 @@ export function initDiagnosis({sessionPromise,retest}) { $('coverage-note').textContent=diagnosis.coverage; renderChecklist(report); $('flow-upkeep').hidden=false; - $('solution-plan').replaceChildren();$('prepare-solution').hidden=!diagnosis.repairable_count; + clearPlan();output.textContent='本轮检测已完成。生成方案后核对具体改动;不能自动处理的项目请按问题中的步骤操作。'; $('quick-proxy').hidden=diagnosis.state==='responding'&&!diagnosis.problems.some(p=>p.action==='proxy-test'); - $('solution-tools').hidden=diagnosis.state==='responding'&&!diagnosis.problems.length&&!$('solution-backups').children.length&&!executionFailed&&!activeCheck; + $('solution-tools').hidden=false; const table=el('table'),head=el('thead'),hr=el('tr'),body=el('tbody');hr.append(el('th','检查项目'),el('th','本次说明'));head.append(hr);table.append(head,body); for(const check of diagnosis.checks){const tr=el('tr');tr.dataset.check=check.id;tr.append(el('td',check.title),el('td',check.text));body.append(tr);} $('checks').replaceChildren(table); diff --git a/envscope/web/index.html b/envscope/web/index.html index 97df6fe..aeb6252 100644 --- a/envscope/web/index.html +++ b/envscope/web/index.html @@ -95,7 +95,7 @@
定期复检,按你批准的基线恢复配置。关闭界面后继续运行,每次处理都有记录。
尚未启用。
diff --git a/envscope/web/minimal.css b/envscope/web/minimal.css index 20bb4fb..1c27292 100644 --- a/envscope/web/minimal.css +++ b/envscope/web/minimal.css @@ -15,7 +15,7 @@ nav{display:flex;align-items:center;gap:18px} .tab[aria-current=page]{background:var(--selected);color:var(--ink);font-weight:600} main{margin:0 auto;max-width:1040px;padding:28px 40px 40px;min-height:calc(100vh - 158px)} .page{max-width:100%} -.connection-status{font-size:11px;color:var(--muted);margin:0 0 20px} +.connection-status{font-size:12px;color:var(--muted);margin:0 0 20px} .connection-banner{border:1px solid var(--ink);border-radius:5px;padding:18px 20px;margin-bottom:24px;max-width:80ch} .connection-banner p{margin:8px 0;font-size:13px} #show-all-checks{margin-bottom:16px} @@ -91,9 +91,9 @@ dl div{display:flex;flex-direction:column;gap:5px;min-width:110px;font-size:12px dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere} .limits{margin-top:30px;padding-top:22px;border-top:1px solid var(--line)} .limits p{margin:10px 0 0} -footer{max-width:960px;margin:0 auto;padding:18px 0;border-top:1px solid var(--line);display:flex;align-items:center;gap:24px;font-size:11px;line-height:1.9;color:var(--muted)} +footer{max-width:960px;margin:0 auto;padding:18px 0;border-top:1px solid var(--line);display:flex;align-items:center;gap:24px;font-size:12px;line-height:1.9;color:var(--muted)} footer span{margin-left:auto;text-align:right}footer br{display:none} -footer .link{text-align:left;font-size:11px} +footer .link{text-align:left;font-size:12px} .subtle{font-size:13px;color:var(--muted);margin-bottom:30px;max-width:72ch} .tool-section{border-top:1px solid var(--line);margin-top:30px;padding-top:22px} .tool-section h2{margin-bottom:10px} @@ -163,8 +163,8 @@ td .link{margin-left:15px} .claude-checklist h3{font-size:14px;font-weight:600;margin:0 0 7px} .claude-checklist p{margin:0 0 10px} .claude-checklist .check-state{font-weight:600} -.claude-checklist button{font-size:11px;text-align:left;padding:6px 9px} -.claude-checklist .note{font-size:11px} +.claude-checklist button{font-size:12px;text-align:left;padding:6px 9px} +.claude-checklist .note{font-size:12px} .upkeep-state{border-top:1px solid var(--line);padding-top:22px} #upkeep-plan{margin-top:20px;max-width:80ch} #upkeep-plan h3{font-size:15px;margin-bottom:12px} @@ -175,7 +175,7 @@ td .link{margin-left:15px} #upkeep-events td{vertical-align:top;font-size:12px;line-height:1.8;overflow-wrap:anywhere} #upkeep-events td:nth-child(2){width:54%;text-align:left} #upkeep-events td:first-child{width:22%} -#upkeep-events .link{margin-left:0;white-space:normal;text-align:left;font-size:11px} +#upkeep-events .link{margin-left:0;white-space:normal;text-align:left;font-size:12px} #config-backups td{font-size:12px;vertical-align:top} .primary{background:var(--ink);border-color:var(--ink);color:#fff;padding:10px 22px} .primary:hover:not(:disabled){background:#444;border-color:#444} @@ -208,8 +208,8 @@ td .link{margin-left:15px} .task-number{font-size:12px;font-variant-numeric:tabular-nums;color:var(--muted)} .usage-tasks h3{grid-column:2;grid-row:1;font-size:14px;font-weight:550;margin-bottom:4px} .usage-tasks p{grid-column:1 / -1;grid-row:3;font-size:12px;line-height:1.8;color:var(--muted);margin:8px 0 0;max-width:73ch} -.task-state{grid-column:2;grid-row:2;font-size:11px;color:var(--muted);padding-top:2px} -.explain-label{font-size:11px;color:var(--muted);margin:18px 0 5px} +.task-state{grid-column:2;grid-row:2;font-size:12px;color:var(--muted);padding-top:2px} +.explain-label{font-size:12px;color:var(--muted);margin:18px 0 5px} .problem .observed{margin-bottom:8px} .problem .cause{margin-bottom:14px} .other-problems{margin-bottom:25px} @@ -238,7 +238,7 @@ td .link{margin-left:15px} .plain-checks>details[open]>summary::before{content:'−'} .plain-checks>details>summary>span:first-of-type{flex:1} .plain-checks>details>summary::-webkit-details-marker{display:none} -.plain-checks .check-state{font-size:11px;font-weight:400;text-align:right;max-width:190px;color:var(--muted)} +.plain-checks .check-state{font-size:12px;font-weight:400;text-align:right;max-width:190px;color:var(--muted)} .plain-checks>details>div{padding-left:34px;font-size:13px;color:var(--muted);max-width:83ch} .plain-checks>details>div h3{color:var(--ink);font-size:12px;margin:20px 0 6px} .plain-checks>details>div>p{margin-bottom:10px} @@ -247,7 +247,7 @@ td .link{margin-left:15px} #technical-details .result-toolbar{margin-top:20px;padding-top:0;border:0} .opensource-option{display:grid;grid-template-columns:170px minmax(0,1fr);column-gap:22px;border-top:1px solid var(--line);padding:20px 0} .opensource-option h3{font-size:14px;font-weight:550} -.opensource-option>.note{grid-column:1;grid-row:2;font-size:11px;margin-top:4px} +.opensource-option>.note{grid-column:1;grid-row:2;font-size:12px;margin-top:4px} .opensource-option>p{grid-column:2;grid-row:1 / span 2;font-size:13px;color:var(--muted);margin-bottom:10px} .opensource-option>a{grid-column:2;justify-self:start} .connection-options{margin:23px 0 26px;max-width:78ch} @@ -258,7 +258,6 @@ td .link{margin-left:15px} .flow-progress li{font-size:12px;color:var(--muted);flex:1;padding:10px 0 12px} .flow-progress li[data-active=true]{color:var(--ink);font-weight:600;border-bottom:2px solid var(--ink);margin-bottom:-1px} #flow-state{margin-bottom:24px;max-width:85ch} -.solution-tools[data-workflow=true]>.actions:first-child{display:none} #solution-plan .connection-evidence{padding:10px 0;border-bottom:1px solid var(--line)} #solution-plan .connection-evidence p{font-size:12px;line-height:1.85;max-width:83ch;margin:12px 0} .manual-items{margin:18px 0} @@ -300,7 +299,7 @@ td .link{margin-left:15px} .inspection-select input { width: 17px; height: 17px; margin: 0; accent-color: #222; } .inspection-row[data-manual="true"] { border-left: 3px solid #a92828; padding-left: 12px; } .inspection-row[data-manual="true"] .inspection-state, .manual-guidance, .inspection-error { color: #a92828; } -.manual-guidance { border-left: 3px solid #a92828; padding-left: 14px; margin: 16px 0; } +.manual-guidance { margin: 16px 0; } .inspection-row summary > .inspection-name { flex: 1; } #checks-plan:empty, #checks-region-recovery:empty { display: none; } #checks-plan { padding: 18px 0; border-bottom: 1px solid #ddd; } @@ -336,10 +335,10 @@ td .link{margin-left:15px} .problem[open]>.cause{margin:12px 0;color:var(--muted)} .problem-actions button{font-size:12px} .solution-tools{padding-top:22px;margin-top:22px} -.solution-tools[data-workflow=true]>.actions:not(#solution-backups){display:none} +.solution-tools[data-workflow=true] #quick-proxy,.solution-tools[data-workflow=true] #quick-proxy-select{display:none} #solution-plan h3{margin:15px 0 9px;font-size:14px} #solution-plan>ul{padding-left:20px;font-size:13px} -#solution-output:empty,#flow-upkeep-output:empty,#solution-backups:empty{display:none} +#solution-output:empty,#solution-result:empty,#flow-upkeep-output:empty,#solution-backups:empty{display:none} .flow-upkeep{margin-top:32px;padding-top:24px} .flow-upkeep>p{margin:8px 0 14px} #flow-watchdog-options{margin:14px 0} diff --git a/pyproject.toml b/pyproject.toml index bb1c667..290eb90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "envscope" -version = "0.24.0" +version = "0.24.1" description = "Minimal desktop environment checks, guarded accounts and practical maintenance tools" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/browser_smoke.py b/scripts/browser_smoke.py index d1e1b98..ab2f9b7 100644 --- a/scripts/browser_smoke.py +++ b/scripts/browser_smoke.py @@ -182,9 +182,10 @@ def never_run(*args, **kwargs): assert config_shape(config_file)['valid'] page.screenshot(path=str(ARTIFACTS/f'{label}-resolved.png')) page.get_by_role('button',name='撤销 Claude 桌面配置',exact=True).click() - expect(page.locator('#solution-output')).to_contain_text('已恢复原配置') + expect(page.locator('#solution-result')).to_contain_text('已恢复原配置') expect(page.locator('#verdict')).to_contain_text('目标环境有不合格项') assert config_file.read_bytes()==original + expect(page.locator('#apply-solution')).to_be_enabled() page.locator('#start').click() expect(page.locator('#apply-solution')).to_be_visible() page.locator('#apply-solution').click() diff --git a/scripts/simple_workflow_smoke.py b/scripts/simple_workflow_smoke.py index 14fbc3a..a2bc965 100644 --- a/scripts/simple_workflow_smoke.py +++ b/scripts/simple_workflow_smoke.py @@ -3,7 +3,7 @@ from pathlib import Path import tempfile import threading -from unittest.mock import Mock +from unittest.mock import Mock, patch from playwright.sync_api import expect, sync_playwright from envscope.legacy import LegacyBridge @@ -34,8 +34,11 @@ def main(): 'timezone_id': 'China Standard Time' if system == 'Windows' else 'Asia/Shanghai'}} writes, errors, requests = [], [], [] fault = [None] + deny_write = [False] def writer(key, value): + if deny_write[0]: + raise PermissionError('fixture: permission denied') writes.append((key, value)) if key == 'timezone': inventory['network']['timezone_id'] = value @@ -98,18 +101,99 @@ def auditor(*args, **kwargs): expect(page.locator('#flow-upkeep')).to_be_visible() expect(page.locator('#more-results')).not_to_have_attribute('open', '') page.screenshot(path=str(out / f'{system}-{width}-problems.png'), full_page=True) + # A problem's recheck must replace the consumed preview with a + # fresh plan, including region fixes outside legacy repair_count. + first_plan = next(r for r in reversed(requests) if r['action'] == 'inspection-plan') + timezone_issue = page.locator('#problems>details').filter(has_text='系统时区与您设置的目标不同') + timezone_issue.locator('summary').click() + timezone_issue.get_by_role('button', name='重新检测', exact=True).click() + expect(page.locator('#apply-solution')).to_be_visible(timeout=30000) + expect(page.locator('#prepare-solution')).to_be_visible() + next_plan = next(r for r in reversed(requests) if r['action'] == 'inspection-plan') + assert next_plan['params']['report_id'] != first_plan['params']['report_id'] + assert not writes, 'rechecking must never apply a plan' + # Expired approval cannot write, and must leave a working way + # to generate another reviewed plan. + for plan in ops.inspection_plans.values(): + plan['deadline'] = 0 + page.locator('#apply-solution').click() + expect(page.locator('#solution-result')).to_contain_text('方案已失效') + expect(page.locator('#prepare-solution')).to_be_enabled() + assert not writes + # Inject a transport error only at the read-only plan boundary. + def fail_preview(route): + if route.request.post_data_json.get('action') == 'inspection-plan': + route.fulfill(status=503, json={'error': '模拟:方案服务暂时不可用'}) + else: + route.continue_() + page.route('**/api/action', fail_preview) + page.locator('#prepare-solution').click() + expect(page.locator('#solution-output')).to_contain_text('暂时不可用') + expect(page.locator('#apply-solution')).to_have_count(0) + expect(page.locator('#prepare-solution')).to_be_enabled() + page.unroute('**/api/action', fail_preview) + page.locator('#prepare-solution').click() + expect(page.locator('#apply-solution')).to_be_enabled() + assert not writes + def waiting_install(route): + if route.request.post_data_json.get('action') == 'inspection-apply': + route.fulfill(json={'state': 'waiting_install', 'reason': '模拟安装:完成后继续复检'}) + else: + route.continue_() + page.route('**/api/action', waiting_install) + page.locator('#apply-solution').click() + expect(page.get_by_role('button', name='安装完成,继续复检', exact=True)).to_be_enabled() + assert not writes + page.unroute('**/api/action', waiting_install) + page.get_by_role('button', name='安装完成,继续复检', exact=True).click() + expect(page.locator('#apply-solution')).to_be_enabled(timeout=30000) + expect(page.locator('#solution-result')).to_contain_text('安装后复检已完成') + # The real transaction creates its recovery metadata, then the + # file replacement fails. Its specific reason must remain visible. + with patch('envscope.remedies._replace', side_effect=PermissionError('fixture denied')): + page.locator('#apply-solution').click() + expect(page.locator('#solution-result')).to_contain_text('已保留原文件备份', timeout=30000) + expect(page.locator('#apply-solution')).to_be_enabled(timeout=30000) + assert {key for key, value in writes} == {'timezone', 'locale'} page.locator('#apply-solution').click() expect(page.locator('#resolution')).to_be_visible(timeout=30000) expect(page.locator('#start')).to_be_enabled() assert {key for key, value in writes} == {'timezone', 'locale'} - assert not page.locator('#problems [data-problem="baseline.timezone"]').count() + expect(page.locator('#solution-result')).to_contain_text('修改已执行') + expect(page.locator('#problems [data-problem="target.timezone"]')).to_have_count(0) + expect(page.locator('#apply-solution')).to_have_count(0) + expect(page.locator('#prepare-solution')).to_be_visible() + # Failed recheck keeps the last execution receipt and explains + # why a fresh repair is unavailable; recovery uses a new audit. + page.route('**/api/audit', lambda route: route.fulfill(status=503, json={'error': 'fixture unavailable'})) + page.locator('#start').click() + expect(page.locator('#solution-output')).to_contain_text('本轮检测未完成', timeout=30000) + expect(page.locator('#prepare-solution')).to_be_visible() + expect(page.locator('#prepare-solution')).to_be_disabled() + expect(page.locator('#solution-result')).to_contain_text('修改已执行') + page.unroute('**/api/audit') + page.locator('#start').click() + expect(page.locator('#start')).to_be_enabled(timeout=30000) page.locator('#flow-upkeep-guard-timezone').check() + page.locator('#flow-upkeep-guard-locale').check() page.locator('#flow-upkeep-prepare').click() expect(page.locator('#flow-upkeep-plan')).to_contain_text('保持时区') assert not maintenance.scheduler.definition page.locator('#flow-upkeep-plan button').click() expect(page.locator('#flow-upkeep-status')).to_contain_text('等待', timeout=10000) assert maintenance.scheduler.definition + inventory['network']['timezone_id'] = 'UTC' + inventory['locale'] = 'fr-FR' + assert maintenance.run_once()['state'] == 'completed' + assert inventory['network']['timezone_id'] == ('Eastern Standard Time' if system == 'Windows' else 'America/New_York') + assert inventory['locale'] == ('en-US' if system == 'Windows' else 'en_US') + status = maintenance.status() + assert status['watchdog']['timezone']['state'] == 'restored' + assert status['watchdog']['locale']['state'] == 'restored' + assert any(e['kind'] == 'watchdog_restored' for e in status['events']) + expect(page.locator('#flow-upkeep-status')).to_contain_text('时区', timeout=25000) + expect(page.locator('#flow-upkeep-status')).to_contain_text('地区格式') + page.screenshot(path=str(out / f'{system}-{width}-guard-restored.png'), full_page=True) page.locator('#flow-upkeep-stop').click() expect(page.locator('#flow-upkeep-status')).to_contain_text('尚未启用') fault[0] = 'permission' @@ -118,6 +202,25 @@ def auditor(*args, **kwargs): expect(page.locator('#problems')).to_contain_text('权限') assert page.locator('#problems>details[data-manual=true]>summary:visible').count() > 0 page.screenshot(path=str(out / f'{system}-{width}-permission.png'), full_page=True) + # A denied OS write must survive the automatic recheck with + # manual recovery guidance and no fabricated repair success. + fault[0] = None + inventory['network']['timezone_id'] = 'UTC' + deny_write[0] = True + previous_writes = list(writes) + page.locator('#start').click() + expect(page.locator('#apply-solution')).to_be_enabled(timeout=30000) + page.locator('#apply-solution').click() + expect(page.locator('#start')).to_be_enabled(timeout=30000) + expect(page.locator('#solution-result')).to_contain_text('修复尚未完成') + expect(page.locator('#solution-result')).to_have_class('solution-output inspection-error') + expect(page.locator('#solution-plan')).to_contain_text('上次地区修改尚未完成') + expect(page.locator('#prepare-solution')).to_be_visible() + expect(page.locator('#apply-solution')).to_have_count(0) + assert writes == previous_writes + expect(page.get_by_role('button', name='查看地区修改与恢复记录', exact=True)).to_have_count(1) + assert inventory['network']['timezone_id'] == 'UTC' + page.screenshot(path=str(out / f'{system}-{width}-repair-denied.png'), full_page=True) for name in ('accounts', 'tools', 'checks', 'upkeep', 'settings', 'coverage', 'history', 'audit'): open_page(page, name) expect(page.locator('#page-' + name)).to_be_visible() @@ -128,6 +231,10 @@ def auditor(*args, **kwargs): results.append({'platform': system, 'width': width, 'single_workflow': True, 'all_issue_summaries_visible': True, 'preview_before_write': True, 'repair_retest': True, 'watchdog_preview_enable_disable': True, + 'problem_recheck_fresh_plan': True, 'expired_plan_no_write': True, + 'preview_error_retry': True, 'failed_recheck_recovery': True, + 'denied_write_manual_recovery': True, 'watchdog_drift_restored_and_logged': True, + 'install_continue_previews_remaining': True, 'file_write_failure_reason_retained': True, 'advanced_navigation': True, 'production_changes': False}) finally: context.close()