diff --git a/.gitattributes b/.gitattributes index b49dc8d..9f8aca5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,5 +13,6 @@ MANIFEST.in text eol=lf .gitignore text eol=lf *.command text eol=lf *.cmd text eol=crlf +licenses/Unicode-3.0.txt text eol=lf envscope/vendor/claude_switcher/** -text envscope/vendor/claude_switcher/VENDOR_MANIFEST.json whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cc126b6..faaf9df 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -37,6 +37,7 @@ jobs: - run: node --check envscope/web/connection.mjs - run: node --check envscope/web/desktop.mjs - run: node --check envscope/web/target.mjs + - run: node --check envscope/web/workflow.mjs - run: node --check envscope/web/coverage.mjs - run: node --check envscope/web/process_guard_ui.mjs - run: node --check envscope/web/components_ui.mjs @@ -67,6 +68,7 @@ jobs: - run: python -m scripts.selection_smoke - run: python -m scripts.check_preferences_smoke - run: python -m scripts.region_lookup_smoke + - run: python -m scripts.simple_workflow_smoke - run: python -m scripts.watchdog_smoke - run: python -m scripts.process_guard_smoke - run: python -m scripts.components_smoke diff --git a/README.en.md b/README.en.md index 9418673..fbdab1b 100644 --- a/README.en.md +++ b/README.en.md @@ -2,7 +2,7 @@ ![EnvScope desktop interface, offline demonstration](docs/images/desktop.png) -Current source: **0.23.0 Alpha**, adding reviewed legacy-program quarantine and restore, detached temporary-browser cleanup with restart recovery, and distinct egress-set statistics. Selection/all, explicit effects, preserved account data and manual handling of unknown files are built in. See [program cleanup](docs/LEGACY_SOFTWARE.md), [browser cleanup](docs/BROWSER_SESSIONS.md) and the [complete requirement ledger](docs/FULL_PARITY_AUDIT.md). Native `.csnap`, `.envsnap`, DMG, optional checks, FlClash maintenance and diagnostic ZIPs remain available. Published installers are listed under Releases. +Current source: **0.24.0 Alpha**. The home page now follows one workflow: confirm region/timezone, run checks, review problems and repairs, then enable persistent protection. Every issue and missing required observation stays visible. Reviewed timezone, locale and Code repairs share one confirmation and automatic recheck. Offline presets cover **247 countries/territories and 417 city/timezone entries**, with manual overrides. Accounts, selective checks, tools and history remain under **More functions**. See the [workflow](docs/SIMPLE_WORKFLOW.md) and [complete requirement ledger](docs/FULL_PARITY_AUDIT.md). Published installers are listed under Releases. Account slots preserve complete independent local profile trees, with normal exit, APFS copy-on-write, verified commits and reviewed recovery after interruption. Recovery preserves displaced copies and never logs in or launches the client. See [account volume scope and tests](docs/ACCOUNT_VOLUMES.md). @@ -38,7 +38,7 @@ target, including extra gates when strict TUN protection is selected. The Models API probe now uses GET; native HTTP probes respect a discovered system HTTP proxy when HTTPS/ALL proxy environment settings are absent. -This usability release adds a searchable, always-visible 32-check catalog, +The searchable check catalog remains available before measurement, inline native information panels, desktop account guidance, visible-page keepalive and explicit disconnected-tab recovery. It preserves the original macOS detailed readers as separate maintenance actions. See the diff --git a/README.md b/README.md index bc22f0d..45e7b7a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ 面向 Windows 和 macOS 的极简桌面环境工具。以 [claude-switcher](https://github.com/YOURSJEYO/claude-switcher) 为功能底座,保留原版维护能力,增加独立检测、事务式账号管理、代理诊断和历史对比。 -当前源码 **0.23.0 Alpha** 增加可恢复的旧软件清理、临时浏览器独立清理进程与重启恢复,以及 48 小时不同出口集合统计。清理支持单项 / 全选,先预览、再确认,保留账号与凭据;未知文件和进程占用转为红色手动指引。参见 [旧软件清理](docs/LEGACY_SOFTWARE.md)、[临时浏览器](docs/BROWSER_SESSIONS.md) 和 [完整功能对照](docs/FULL_PARITY_AUDIT.md)。原有 `.csnap` / `.envsnap` / DMG、持久可选检查、FlClash、日志与脱敏 ZIP 均保留。公开安装包以 Releases 为准。 +当前源码 **0.24.0 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)。 **0.20.0 已加入**旧后台任务的可逆迁移:单项选择或全选、预览访问与配置影响、保存原定义后停用、自动复检,以及恢复原任务。新守护会监测旧任务重新出现,发现冲突时暂停自身操作并留痕。一键流程按新检测结果分阶段生成方案。参见 [旧后台任务迁移](docs/legacy-components.md)。公开安装包以 Releases 为准。 @@ -32,7 +34,7 @@ Scrapling 仅用于开发前期抓取和核对方案、项目与文档,不属 ![EnvScope 桌面界面,离线演示](docs/images/desktop.png) -界面采用黑白配色和固定导航;问题、处理入口与复检结果直接展示。截图来自离线配置修复演示,未运行真实网络检测。设计约定见 [桌面界面](docs/UI_DESIGN.md)。 +界面采用黑白配色和单页主流程;问题、处理入口与复检结果直接展示。截图来自离线模拟环境,未运行真实网络检测。设计约定见 [桌面界面](docs/UI_DESIGN.md)。 ## 一键检测 @@ -40,13 +42,12 @@ Windows 桌面包解压后双击 `EnvScope.exe`。macOS 桌面包的入口为 `E [下载 Windows / macOS 桌面包](https://github.com/qudadd/envscope/releases)。macOS 分 Apple Silicon 与 Intel,按处理器选择。Alpha 包未获商业代码签名 / Apple 公证。 -1. 首次打开跟着教学选择使用方式;打开平时使用的连接工具,点“一键检测与修复”。 -2. 检测后自动生成统一方案;“环境设置”可选择使用范围、是否完善 Code 连接,以及自动或指定本机 HTTP 代理。 -3. 审核具体影响,点“确认配置并修复”。写入前再次验证所选代理,失败不写入,也不回退直连。 - 需要启动 / 修复代理时,先完成这一阶段并自动复检;后续路由、网卡和守护改动会按新结果重新预览,不能提前猜测尚未出现的接口。 -4. 自动复检区分已确认消除、仍存在、本次无法核实和新问题。桌面用户可点“复检后打开桌面版”;配置改变后保存工作,再点“保存后复检并重开”。未确认正常退出时不会强制终止。 -5. 在自己的 Claude 登录并发一条消息;Code 用户重开常用终端或会话。手动确认收到回复不会改变技术检测结论。仍不能用时,按实际现象获取步骤。 -6. 连接正常后,在首页“保持本次设置”预览并启用周期复检;可选保持已验证 Code 配置,同页可停用。独立维护页继续保留。 +1. 确认实际使用地区、节点地区和城市。出口 IP 可建议节点地区与时区;不会代填实际所在地。需要时展开用途与连接选项。 +2. 点“确认并一键检测”。首页列出不合格项目、缺少的必需证据和下一步;展开对应问题查看原因与手动步骤。 +3. 核对修复方案,点“确认修复并复检”。写入前保存恢复记录;代理连接未验证时不会写入。需要先启动客户端时,完成并复检后再审核后续改动。 +4. 在“持久化守护”勾选需保持的配置,预览并确认启用;同页可停用。账号、选择检查 / 全选、备份与高级修复从“更多功能”进入。 + +复检区分已消除、仍存在和仍缺少证据的问题。“详细记录与使用帮助”保留打开 / 正常重开 Claude、实际试用指引和技术证据。Code 配置修改后需要重开会话;实际账号使用仍须在自己的 Claude 中确认。 配置完善优先测试 Code 已有的本机 HTTP 代理,再测试已发现的候选;明确指定地址时不试用其他连接。自定义接口、Bedrock / Vertex / Foundry 和未明确更换的远程代理保留其原路径。只改用户配置文件;项目与组织设置可能覆盖它,实际会话仍需重开确认。完全相同的设置不会仅为排版而重写。复检单列配置文件凭据,不把写入成功或账号手动确认当作隐私通过。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3caedee..4995934 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,16 @@ # Third-party origins +## Region and timezone data + +`envscope/region_data.py` contains derived data from Unicode CLDR 48, revision +`acd6d88ae493633240e19a87a721076a8a75c310`, under Unicode-3.0. +The complete license is in `licenses/Unicode-3.0.txt`. The generator +`scripts/generate_region_data.py` records each input SHA-256 and uses the official +CLDR Windows timezone mappings, Chinese place names, timezone aliases and likely +language defaults. These are editable configuration suggestions, independent of +Claude's supported-region policy and never evidence of a user's physical location. +Source: https://github.com/unicode-org/cldr/tree/acd6d88ae493633240e19a87a721076a8a75c310 + ## WireGuard interoperability The read-only Windows service adapter is EnvScope code using documented Windows diff --git a/docs/SIMPLE_WORKFLOW.md b/docs/SIMPLE_WORKFLOW.md new file mode 100644 index 0000000..aa5f14a --- /dev/null +++ b/docs/SIMPLE_WORKFLOW.md @@ -0,0 +1,24 @@ +# 简化的一键流程(0.24) + +首页只按实际操作顺序展示四个部分,保留黑白、系统字体和桌面宽度。 + +1. **地区与时区**:填写实际所在地;读取当前出口 IP,建议节点地区及对应城市。选择城市会匹配 Windows / macOS 时区、地区格式和语言目标。手动选择和已保存目标不会被迟到的查询结果覆盖。用途与连接方式按需展开。 +2. **一键检测**:完成目标确认后开始。结果说明必需项通过、不合格和待完成的数量;所有问题标题、实际观测与处理方式直接可见。需要权限、缺少证据和本版不支持的检查分别说明,不计为通过。 +3. **修复方案**:先列出具体字段、原值与目标或可执行操作。时区、地区格式与 Code 配置合并审核,确认后才执行,随后自动复检。保留备份和恢复入口。需要先启动客户端的操作仍分阶段复检;无法自动处理的项目标红并给出步骤。 +4. **持久化守护**:选择周期以及需要保持的时区、地区格式和代理;额外客户端与 Code 选项折叠。预览具体基线并确认启用,同页可停用。后台仅执行已批准的范围并保存结果,未获得权限的修改保留原因。 + +顶部“更多功能”提供全部检查(选择 / 全选 / 批量处理)、账号资料、工具与备份、守护与高级修复、历史、设置与更新、环境全览。详细测量、帮助和 Claude 启动入口放在首页末尾,不妨碍主流程。 + +## 地区覆盖 + +保留原有预设 ID,补充 Unicode CLDR 48 数据,合计 417 个城市 / 时区预设,覆盖 247 个国家和地区;地区名称目录另包含无时区映射的历史或特殊地区,这些地区明确提供自定义入口。城市列表是时区代表地点,不是完整地名库;同一国家的其他城市可选对应时区或手动填写。 + +数据固定于 CLDR 提交 `acd6d88ae493633240e19a87a721076a8a75c310`,生成器校验源文件 SHA256,随包离线提供。来源、许可和再生成脚本见 [第三方声明](../THIRD_PARTY_NOTICES.md)、[生成器](../scripts/generate_region_data.py)。IP 时区别名可匹配相同国家的预设;部分或失败的查询要求手动选择。 + +节点地区是出口与配置目标;实际所在地仍单独填写。地区预设不改变 Claude 官方服务支持条件,CN / RU 的现有地区检查继续保留。时区与语言配置本身不保证账号可用或服务端设备判断。 + +## 验收范围 + +`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 7aa705a..7f00703 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -1,49 +1,35 @@ # 桌面界面 -0.7.1 根据真实操作反馈调整可发现性。保留白、灰、黑六个现有颜色和系统字体; -左侧增加“全部检查”,首页提供同名入口,32 项在测量前也有可搜索的目录。 -每行展开用途、结果、下一步和保持方法。环境信息就近显示并获得键盘焦点, -账号页在表格前说明 A/B 的实际含义。页首连接状态及断线恢复入口避免旧页面无反馈。 -设计复核:不新增营销横幅、状态卡片或装饰;连接框仅在断线或目录失败时出现。 -1024、1280、1440 宽度均检查导航、信息面板、账号、筛选和异常状态。 +0.24 将首页收敛为一条流程:确认地区与时区、检测、查看问题与修复方案、启用持久化守护。面向 Windows 和 macOS,暂不适配手机端。 -0.7 在实际试用步骤内加入打开与正常重开的入口。启动条件收起展示,结果与具体原因 -留在按钮下方;不增加导航、卡片或第二个首页主按钮。打开请求不显示为登录成功。 +顶部仅保留 EnvScope、版本、检测与修复、“更多功能”。原有账号、全部检查、工具、历史和高级维护完整保留在菜单中。首页不显示空的结果表;首次使用以简短行内提示引导填写目标,完整教学可从页脚重看。 -0.6 使用一个主按钮和一条四步进度线:检测、核对方案、修复复检、日常保持。 -环境选项默认收起,开始检测后收起并锁定至本轮完成。自动生成的具体方案成为主内容, -确认按钮与备份撤销保留在当前页。分项状态移入技术记录;同页提供长期保持的预览、 -启用与停用。黑白、系统字体、细分隔线不变,不新增卡片或装饰组件。 +地区设置默认显示实际使用地区、节点地区、城市 / 时区所在地和目标时区。选择城市自动匹配地区格式与语言;用途、连接方式和其他要求折叠。节点地区依据出口建议,用户手动选择优先,实际所在地独立填写。 -0.5 保留黑白、系统字体和窄侧栏,改为任务顺序:首次教学 → 检查 → 处理 → 实际试用 → 日常保持。 -教学是正常页面,三步可退回、跳过和重看;使用范围只在开始检测时保存。 -首页先展开一项最优先问题;其余问题、14 项白话核对和完整技术记录按需要展开。 -白话核对解释查什么、为何影响使用、现在是否要动以及怎样保持,不以术语作为主标题。 -手动试用确认、网络探测与后台状态分别显示,重新检测清除旧的本页试用确认。 -后台页直接呈现最近执行时间、问题数、恢复受阻原因、方案及事件,不展示原始 JSON。 -未注册、等待首轮、超期、失败和已完成不能使用同一种成功提示。 +检测后,所有问题标题、观测和处理方式逐项可见。展开一项可查看原因与步骤;缺少必需证据也必须有明确入口。不能自动修复的项目用红色文字说明。技术记录、已通过的观测与帮助默认折叠。 -EnvScope 是供普通用户排查 Claude 环境的本机工具。主要任务是启动一次检测、理解需要处理的项目,再进入账号或维护操作。Windows 和 macOS 是本阶段的适配范围。 - -采用安静的桌面工具布局:固定的窄导航,左对齐的正文,清晰的单个检测按钮。完成后直接呈现具体问题、事实、原因、步骤和入口。审核改动、确认执行和复检留在当前页。原始证据用于导出,不要求用户展开 JSON 自行归因。 +修复区先列具体改动,只保留一个确认修复并复检按钮。持久化守护紧随其后:选择间隔与需保持的配置,预览、确认、启停都在同页。后台权限或修复失败必须显示原因,不能仅凭写入成功就显示环境合格。 ```text -EnvScope 环境检测 一键检测 -版本 检测范围和本次状态 - -检测 问题概览 一站式处理 -账号 本次方案 确认执行 -工具 具体问题 / 原因 / 步骤 处理入口 -历史 复检结果 撤销修改 -设置 检查记录与检测边界 导出 -``` +EnvScope 版本 检测与修复 更多功能 + +Claude 环境检查 一键检测 +地区与时区 调整目标 -视觉变量:纸面白 `#ffffff`,导航灰 `#f7f7f7`,正文黑 `#202020`,辅助文字 `#646464`,分隔线 `#e6e6e6`,当前导航 `#eaeaea`。黑底只用于主要操作;状态同时通过文字与字重区分。无品牌强调色、卡片、阴影、渐变、装饰图标或大号数字。 +目标环境结论 全部检查与证据 +具体问题 / 当前值与目标 / 处理方式 -字体使用操作系统的界面字体,Windows 优先 Segoe UI / Microsoft YaHei UI,macOS 优先系统字体 / PingFang SC;字号 12、13、14、18、26。说明文字的对比度和宽度优先于极小字号。代码与证据才使用等宽字体。 +修复方案 +具体改动 → 确认修复并复检 + +持久化守护 +周期 / 保持范围 → 预览守护方案 + +详细记录与使用帮助(按需展开) +``` -导航宽 192 像素;正文最大 1104 像素,内边距 40 像素。按钮与输入框用 5 像素圆角表达可操作性。分隔线只用于分组与列表。交互即时,无装饰动画;键盘焦点可见,处理方案和复检完成后定位到对应内容。 +视觉使用白、灰、黑及手动处理的红色文字。系统字体、细分隔线、可见键盘焦点;黑底仅用于主要操作。无渐变、阴影、装饰图标、营销标题或状态卡片。正文居中、最大 1040 像素,桌面内边距 40 像素。 -复核:拒绝暖色纸面、营销标题、状态卡片和多余编号;调整原界面的过小字体与漫长的等待列表。通过真实页面截图检查初始、检测、账号、工具、历史和设置页面,并在 1024、1280、1440 像素桌面宽度检查溢出。 +主流程和菜单在 1024、1280、1440 宽度通过隔离浏览器测试与截图检查;代理、时区和守护均使用一次性模拟数据。截图不能作为真实 Claude 登录或网络可用的证据。详见 [流程与地区覆盖](SIMPLE_WORKFLOW.md)。 -设计过程参考已安装的 Anthropic [frontend-design](https://github.com/anthropics/skills/tree/dbd4588f9e1033efb41dad4bef2f7947c8993d44/skills/frontend-design) Skill。它是开发工具,不随 EnvScope 分发,也不属于运行依赖。 +设计参考已安装的 Anthropic [frontend-design](https://github.com/anthropics/skills/tree/dbd4588f9e1033efb41dad4bef2f7947c8993d44/skills/frontend-design) Skill。它是开发工具,不随 EnvScope 分发。 diff --git a/docs/images/desktop.png b/docs/images/desktop.png index 69713b5..edf9cd4 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 1553b76..d05b805 100644 --- a/envscope/__init__.py +++ b/envscope/__init__.py @@ -1,4 +1,4 @@ """EnvScope: evidence-first environment diagnostics.""" -__version__ = "0.23.0" +__version__ = "0.24.0" __release__ = "v" + __version__ + "-alpha.1" diff --git a/envscope/operations.py b/envscope/operations.py index 1bb9d52..37aad0c 100644 --- a/envscope/operations.py +++ b/envscope/operations.py @@ -457,8 +457,11 @@ def _run(self, action, params, confirmed=False, report=None, preflight=None): raise ValueError('unexpected action parameters') return self.regions.undo(confirmed) if action == 'inspection-plan': - if set(params) - {'report_id', 'check_ids', 'group', 'node'}: + if set(params) - {'report_id', 'check_ids', 'group', 'node', 'enhance_cli', 'proxy'}: raise ValueError('invalid inspection parameters') + enhance_cli = params.get('enhance_cli', False) + if type(enhance_cli) is not bool: + raise ValueError('完善 Code 配置需要明确的布尔选项') from .inspection import validate_selection, repair_options import secrets checks = validate_selection(params.get('check_ids')) @@ -475,6 +478,8 @@ def _run(self, action, params, confirmed=False, report=None, preflight=None): # Missing evidence never authorizes a write; it must not block unrelated reviewed fixes. missing = [key for key in checks if key not in found and key in report.get('environment', {}).get('required', [])] checks = [key for key in checks if key in found] + if (enhance_cli or params.get('proxy') is not None) and 'extra.cli_env' not in checks: + raise ValueError('完善 Code 配置必须包含已检测的 Code 连接配置项目') options = repair_options(report) manual = [{'id': k, 'title': found[k]['title'], 'steps': options[k]['steps']} for k in checks if options[k]['manual']] @@ -484,20 +489,21 @@ def _run(self, action, params, confirmed=False, report=None, preflight=None): if 'baseline.country' in checks and params.get('node'): components.append('node') elif 'baseline.country' in checks and found['baseline.country']['status'] != 'pass': - manual.append({'id': 'baseline.country', 'title': '节点地区需要处理', 'steps': ['在下方读取代理组并选择目标地区的已有节点;无法读取时在代理客户端手动选择。节点名称不证明出口地区,完成后复检。']}) + manual.append({'id': 'baseline.country', 'title': '节点地区需要处理', 'steps': ['展开上方的出口地区问题,点击“核对地区与节点”;也可从“更多功能 → 全部检查 → 节点地区与恢复记录”读取代理组,选择目标地区的已有节点。', '无法读取控制接口时,在代理客户端手动选择。节点名称不证明出口地区,完成后重新检测。']}) region = self.regions.prepare(report, components, params.get('group', ''), params.get('node', '')) if components else {} if components and region.get('state') != 'planned': manual.append({'id': 'region', 'title': '地区配置需要手动确认', 'steps': [region.get('reason', '请重新检测')]}) - solution = self.solutions.prepare(report, check_ids=checks) if checks else {} + solution = self.solutions.prepare(report, check_ids=checks, enhance_cli=enhance_cli, proxy=params.get('proxy')) if checks else {} for reason in solution.get('manual', []): manual.append({'id': 'configuration', 'title': '配置需要手动处理', 'steps': [reason]}) effects = region.get('effects', []) + solution.get('effects', []) if not effects: - return {'state': 'guided', 'manual_items': manual, 'effects': [], 'reason': '没有可直接执行的改动;请按红色项目的步骤处理后复查。' if manual else '所选项目没有可执行的修改,请根据检测结果继续。'} + return {'state': 'guided', 'manual_items': manual, 'effects': [], 'connections': solution.get('connections', []), 'reason': '没有可直接执行的改动;请按红色项目的步骤处理后复查。' if manual else '所选项目没有可执行的修改,请根据检测结果继续。'} token = secrets.token_urlsafe(20) self.inspection_plans = {token: {'region': region.get('plan_id'), 'solution': solution.get('plan_id'), 'deadline': time.monotonic() + 90, 'report_id': report['report_id'], 'fingerprint': fingerprint(settings)}} return {'state': 'planned', 'plan_id': token, 'effects': effects, 'manual_items': manual, + 'connections': solution.get('connections', []), 'reason': '确认一次后依次执行,失败则停止;保留恢复记录。配置写入后自动复检,不能自动解决的项目仍标红。' + (region.get('reason', '') if components else '')} if action == 'inspection-apply': if set(params) != {'plan_id'} or not isinstance(params['plan_id'], str): @@ -510,14 +516,16 @@ def _run(self, action, params, confirmed=False, report=None, preflight=None): return {'state': 'retest_required', 'reason': '方案已过期或检测已改变,请重新生成'} del self.inspection_plans[params['plan_id']] results = [] + before = {'problems': [{'id': p['id'], 'title': p['title']} for p in report['diagnosis']['problems']]} if plan['region']: results.append(self.regions.apply(plan['region'], report, True)) if results[-1]['state'] != 'completed': - return {**results[-1], 'results': results, 'needs_retest': True} + return {**results[-1], 'results': results, 'needs_retest': True, 'before': before} if plan['solution']: results.append(self.solutions.apply(plan['solution'], report, True)) last = results[-1] return {'state': last['state'], 'results': results, 'needs_retest': last.get('needs_retest', True), + 'before': last.get('before', before), 'next_review':last.get('next_review',False), 'backups': [b for r in results for b in r.get('backups', [])], 'reason': last.get('reason', '操作已返回,请复检实际结果')} diff --git a/envscope/region_data.py b/envscope/region_data.py new file mode 100644 index 0000000..18200bf --- /dev/null +++ b/envscope/region_data.py @@ -0,0 +1,686 @@ +"""Generated CLDR 48 region data. Unicode-3.0; see licenses/Unicode-3.0.txt. +Regenerate using scripts/generate_region_data.py. Preferences, not location evidence. +""" + +CLDR_REVISION = 'acd6d88ae493633240e19a87a721076a8a75c310' +SOURCE_SHA256 = {'common/supplemental/windowsZones.xml': '9cf3db6a31fb382fee21b70be6feba1e82766b0fcd06e6261fb7936a73e537ff', 'common/supplemental/likelySubtags.xml': 'd7732172848cc2870d0d316818190e704bc6614486168dc52af56ee1427b6860', 'common/main/zh.xml': 'b1ef8bcadcf19fa8914d9ba812a7bcec124c72fecbf4acea02e4c8bd2fe51866', 'common/bcp47/timezone.xml': '54ce7d9f1d385101e24356df7d48ae62cf2acc4421378da60a4007d2488c1aad', 'LICENSE': 'b4c0ae8ef04f7059f96ce5bbe0467f9fe6f6d81bbe13517701dfeb961fb4d0b6'} + +COUNTRIES = ( + ('AC', '阿森松岛'), + ('AD', '安道尔'), + ('AE', '阿拉伯联合酋长国'), + ('AF', '阿富汗'), + ('AG', '安提瓜和巴布达'), + ('AI', '安圭拉'), + ('AL', '阿尔巴尼亚'), + ('AM', '亚美尼亚'), + ('AO', '安哥拉'), + ('AQ', '南极洲'), + ('AR', '阿根廷'), + ('AS', '美属萨摩亚'), + ('AT', '奥地利'), + ('AU', '澳大利亚'), + ('AW', '阿鲁巴'), + ('AX', '奥兰群岛'), + ('AZ', '阿塞拜疆'), + ('BA', '波斯尼亚和黑塞哥维那'), + ('BB', '巴巴多斯'), + ('BD', '孟加拉国'), + ('BE', '比利时'), + ('BF', '布基纳法索'), + ('BG', '保加利亚'), + ('BH', '巴林'), + ('BI', '布隆迪'), + ('BJ', '贝宁'), + ('BL', '圣巴泰勒米'), + ('BM', '百慕大'), + ('BN', '文莱'), + ('BO', '玻利维亚'), + ('BQ', '荷属加勒比区'), + ('BR', '巴西'), + ('BS', '巴哈马'), + ('BT', '不丹'), + ('BV', '布韦岛'), + ('BW', '博茨瓦纳'), + ('BY', '白俄罗斯'), + ('BZ', '伯利兹'), + ('CA', '加拿大'), + ('CC', '科科斯(基林)群岛'), + ('CD', '刚果(金)'), + ('CF', '中非共和国'), + ('CG', '刚果(布)'), + ('CH', '瑞士'), + ('CI', '科特迪瓦'), + ('CK', '库克群岛'), + ('CL', '智利'), + ('CM', '喀麦隆'), + ('CN', '中国大陆'), + ('CO', '哥伦比亚'), + ('CP', '克利珀顿岛'), + ('CQ', '萨克岛'), + ('CR', '哥斯达黎加'), + ('CU', '古巴'), + ('CV', '佛得角'), + ('CW', '库拉索'), + ('CX', '圣诞岛'), + ('CY', '塞浦路斯'), + ('CZ', '捷克'), + ('DE', '德国'), + ('DG', '迪戈加西亚岛'), + ('DJ', '吉布提'), + ('DK', '丹麦'), + ('DM', '多米尼克'), + ('DO', '多米尼加共和国'), + ('DZ', '阿尔及利亚'), + ('EA', '休达及梅利利亚'), + ('EC', '厄瓜多尔'), + ('EE', '爱沙尼亚'), + ('EG', '埃及'), + ('EH', '西撒哈拉'), + ('ER', '厄立特里亚'), + ('ES', '西班牙'), + ('ET', '埃塞俄比亚'), + ('FI', '芬兰'), + ('FJ', '斐济'), + ('FK', '福克兰群岛'), + ('FM', '密克罗尼西亚'), + ('FO', '法罗群岛'), + ('FR', '法国'), + ('GA', '加蓬'), + ('GB', '英国'), + ('GD', '格林纳达'), + ('GE', '格鲁吉亚'), + ('GF', '法属圭亚那'), + ('GG', '根西岛'), + ('GH', '加纳'), + ('GI', '直布罗陀'), + ('GL', '格陵兰'), + ('GM', '冈比亚'), + ('GN', '几内亚'), + ('GP', '瓜德罗普'), + ('GQ', '赤道几内亚'), + ('GR', '希腊'), + ('GS', '南乔治亚和南桑威奇群岛'), + ('GT', '危地马拉'), + ('GU', '关岛'), + ('GW', '几内亚比绍'), + ('GY', '圭亚那'), + ('HK', '中国香港'), + ('HM', '赫德岛和麦克唐纳群岛'), + ('HN', '洪都拉斯'), + ('HR', '克罗地亚'), + ('HT', '海地'), + ('HU', '匈牙利'), + ('IC', '加纳利群岛'), + ('ID', '印度尼西亚'), + ('IE', '爱尔兰'), + ('IL', '以色列'), + ('IM', '马恩岛'), + ('IN', '印度'), + ('IO', '英属印度洋领地'), + ('IQ', '伊拉克'), + ('IR', '伊朗'), + ('IS', '冰岛'), + ('IT', '意大利'), + ('JE', '泽西岛'), + ('JM', '牙买加'), + ('JO', '约旦'), + ('JP', '日本'), + ('KE', '肯尼亚'), + ('KG', '吉尔吉斯斯坦'), + ('KH', '柬埔寨'), + ('KI', '基里巴斯'), + ('KM', '科摩罗'), + ('KN', '圣基茨和尼维斯'), + ('KP', '朝鲜'), + ('KR', '韩国'), + ('KW', '科威特'), + ('KY', '开曼群岛'), + ('KZ', '哈萨克斯坦'), + ('LA', '老挝'), + ('LB', '黎巴嫩'), + ('LC', '圣卢西亚'), + ('LI', '列支敦士登'), + ('LK', '斯里兰卡'), + ('LR', '利比里亚'), + ('LS', '莱索托'), + ('LT', '立陶宛'), + ('LU', '卢森堡'), + ('LV', '拉脱维亚'), + ('LY', '利比亚'), + ('MA', '摩洛哥'), + ('MC', '摩纳哥'), + ('MD', '摩尔多瓦'), + ('ME', '黑山'), + ('MF', '法属圣马丁'), + ('MG', '马达加斯加'), + ('MH', '马绍尔群岛'), + ('MK', '北马其顿'), + ('ML', '马里'), + ('MM', '缅甸'), + ('MN', '蒙古'), + ('MO', '中国澳门'), + ('MP', '北马里亚纳群岛'), + ('MQ', '马提尼克'), + ('MR', '毛里塔尼亚'), + ('MS', '蒙特塞拉特'), + ('MT', '马耳他'), + ('MU', '毛里求斯'), + ('MV', '马尔代夫'), + ('MW', '马拉维'), + ('MX', '墨西哥'), + ('MY', '马来西亚'), + ('MZ', '莫桑比克'), + ('NA', '纳米比亚'), + ('NC', '新喀里多尼亚'), + ('NE', '尼日尔'), + ('NF', '诺福克岛'), + ('NG', '尼日利亚'), + ('NI', '尼加拉瓜'), + ('NL', '荷兰'), + ('NO', '挪威'), + ('NP', '尼泊尔'), + ('NR', '瑙鲁'), + ('NU', '纽埃'), + ('NZ', '新西兰'), + ('OM', '阿曼'), + ('PA', '巴拿马'), + ('PE', '秘鲁'), + ('PF', '法属波利尼西亚'), + ('PG', '巴布亚新几内亚'), + ('PH', '菲律宾'), + ('PK', '巴基斯坦'), + ('PL', '波兰'), + ('PM', '圣皮埃尔和密克隆群岛'), + ('PN', '皮特凯恩群岛'), + ('PR', '波多黎各'), + ('PS', '巴勒斯坦领土'), + ('PT', '葡萄牙'), + ('PW', '帕劳'), + ('PY', '巴拉圭'), + ('QA', '卡塔尔'), + ('RE', '留尼汪'), + ('RO', '罗马尼亚'), + ('RS', '塞尔维亚'), + ('RU', '俄罗斯'), + ('RW', '卢旺达'), + ('SA', '沙特阿拉伯'), + ('SB', '所罗门群岛'), + ('SC', '塞舌尔'), + ('SD', '苏丹'), + ('SE', '瑞典'), + ('SG', '新加坡'), + ('SH', '圣赫勒拿'), + ('SI', '斯洛文尼亚'), + ('SJ', '斯瓦尔巴和扬马延'), + ('SK', '斯洛伐克'), + ('SL', '塞拉利昂'), + ('SM', '圣马力诺'), + ('SN', '塞内加尔'), + ('SO', '索马里'), + ('SR', '苏里南'), + ('SS', '南苏丹'), + ('ST', '圣多美和普林西比'), + ('SV', '萨尔瓦多'), + ('SX', '荷属圣马丁'), + ('SY', '叙利亚'), + ('SZ', '斯威士兰'), + ('TA', '特里斯坦-达库尼亚群岛'), + ('TC', '特克斯和凯科斯群岛'), + ('TD', '乍得'), + ('TF', '法属南部领地'), + ('TG', '多哥'), + ('TH', '泰国'), + ('TJ', '塔吉克斯坦'), + ('TK', '托克劳'), + ('TL', '东帝汶'), + ('TM', '土库曼斯坦'), + ('TN', '突尼斯'), + ('TO', '汤加'), + ('TR', '土耳其'), + ('TT', '特立尼达和多巴哥'), + ('TV', '图瓦卢'), + ('TW', '中国台湾'), + ('TZ', '坦桑尼亚'), + ('UA', '乌克兰'), + ('UG', '乌干达'), + ('UM', '美国本土外小岛屿'), + ('US', '美国'), + ('UY', '乌拉圭'), + ('UZ', '乌兹别克斯坦'), + ('VA', '梵蒂冈'), + ('VC', '圣文森特和格林纳丁斯'), + ('VE', '委内瑞拉'), + ('VG', '英属维尔京群岛'), + ('VI', '美属维尔京群岛'), + ('VN', '越南'), + ('VU', '瓦努阿图'), + ('WF', '瓦利斯和富图纳'), + ('WS', '萨摩亚'), + ('XK', '科索沃'), + ('YE', '也门'), + ('YT', '马约特'), + ('ZA', '南非'), + ('ZM', '赞比亚'), + ('ZW', '津巴布韦'), +) + +REGIONS = ( + ('ad-europe-andorra', '安道尔 · 安道尔', 'AD', 'Europe/Andorra', 'W. Europe Standard Time', 'ca-AD', ('Europe/Andorra',)), + ('ae-asia-dubai', '阿拉伯联合酋长国 · 迪拜', 'AE', 'Asia/Dubai', 'Arabian Standard Time', 'ar-AE', ('Asia/Dubai',)), + ('af-asia-kabul', '阿富汗 · 喀布尔', 'AF', 'Asia/Kabul', 'Afghanistan Standard Time', 'fa-AF', ('Asia/Kabul',)), + ('ag-america-antigua', '安提瓜和巴布达 · 安提瓜', 'AG', 'America/Antigua', 'SA Western Standard Time', '', ('America/Antigua',)), + ('ai-america-anguilla', '安圭拉 · 安圭拉', 'AI', 'America/Anguilla', 'SA Western Standard Time', '', ('America/Anguilla',)), + ('al-europe-tirane', '阿尔巴尼亚 · 地拉那', 'AL', 'Europe/Tirane', 'Central Europe Standard Time', 'sq-AL', ('Europe/Tirane',)), + ('am-asia-yerevan', '亚美尼亚 · 埃里温', 'AM', 'Asia/Yerevan', 'Caucasus Standard Time', 'hy-AM', ('Asia/Yerevan',)), + ('ao-africa-luanda', '安哥拉 · 罗安达', 'AO', 'Africa/Luanda', 'W. Central Africa Standard Time', 'pt-AO', ('Africa/Luanda',)), + ('aq-antarctica-casey', '南极洲 · 卡塞', 'AQ', 'Antarctica/Casey', 'Central Pacific Standard Time', '', ('Antarctica/Casey',)), + ('aq-antarctica-davis', '南极洲 · 戴维斯', 'AQ', 'Antarctica/Davis', 'SE Asia Standard Time', '', ('Antarctica/Davis',)), + ('aq-antarctica-dumontdurville', '南极洲 · 迪蒙·迪维尔', 'AQ', 'Antarctica/DumontDUrville', 'West Pacific Standard Time', '', ('Antarctica/DumontDUrville',)), + ('aq-antarctica-mawson', '南极洲 · 莫森', 'AQ', 'Antarctica/Mawson', 'West Asia Standard Time', '', ('Antarctica/Mawson',)), + ('aq-antarctica-mcmurdo', '南极洲 · 麦克默多', 'AQ', 'Antarctica/McMurdo', 'New Zealand Standard Time', '', ('Antarctica/McMurdo', 'Antarctica/South_Pole')), + ('aq-antarctica-palmer', '南极洲 · 帕尔默', 'AQ', 'Antarctica/Palmer', 'SA Eastern Standard Time', '', ('Antarctica/Palmer',)), + ('aq-antarctica-rothera', '南极洲 · 罗瑟拉', 'AQ', 'Antarctica/Rothera', 'SA Eastern Standard Time', '', ('Antarctica/Rothera',)), + ('aq-antarctica-syowa', '南极洲 · 昭和', 'AQ', 'Antarctica/Syowa', 'E. Africa Standard Time', '', ('Antarctica/Syowa',)), + ('aq-antarctica-vostok', '南极洲 · 沃斯托克', 'AQ', 'Antarctica/Vostok', 'Central Asia Standard Time', '', ('Antarctica/Vostok',)), + ('ar-america-argentina-la-rioja', '阿根廷 · 拉里奥哈', 'AR', 'America/Argentina/La_Rioja', 'Argentina Standard Time', 'es-AR', ('America/Argentina/La_Rioja',)), + ('ar-america-argentina-rio-gallegos', '阿根廷 · 里奥加耶戈斯', 'AR', 'America/Argentina/Rio_Gallegos', 'Argentina Standard Time', 'es-AR', ('America/Argentina/Rio_Gallegos',)), + ('ar-america-argentina-salta', '阿根廷 · 萨尔塔', 'AR', 'America/Argentina/Salta', 'Argentina Standard Time', 'es-AR', ('America/Argentina/Salta',)), + ('ar-america-argentina-san-juan', '阿根廷 · 圣胡安', 'AR', 'America/Argentina/San_Juan', 'Argentina Standard Time', 'es-AR', ('America/Argentina/San_Juan',)), + ('ar-america-argentina-san-luis', '阿根廷 · 圣路易斯', 'AR', 'America/Argentina/San_Luis', 'Argentina Standard Time', 'es-AR', ('America/Argentina/San_Luis',)), + ('ar-america-argentina-tucuman', '阿根廷 · 图库曼', 'AR', 'America/Argentina/Tucuman', 'Argentina Standard Time', 'es-AR', ('America/Argentina/Tucuman',)), + ('ar-america-argentina-ushuaia', '阿根廷 · 乌斯怀亚', 'AR', 'America/Argentina/Ushuaia', 'Argentina Standard Time', 'es-AR', ('America/Argentina/Ushuaia',)), + ('ar-america-buenos-aires', '阿根廷 · 布宜诺斯艾利斯', 'AR', 'America/Buenos_Aires', 'Argentina Standard Time', 'es-AR', ('America/Buenos_Aires', 'America/Argentina/Buenos_Aires')), + ('ar-america-catamarca', '阿根廷 · 卡塔马卡', 'AR', 'America/Catamarca', 'Argentina Standard Time', 'es-AR', ('America/Catamarca', 'America/Argentina/Catamarca', 'America/Argentina/ComodRivadavia')), + ('ar-america-cordoba', '阿根廷 · 科尔多瓦', 'AR', 'America/Cordoba', 'Argentina Standard Time', 'es-AR', ('America/Cordoba', 'America/Argentina/Cordoba', 'America/Rosario')), + ('ar-america-jujuy', '阿根廷 · 胡胡伊', 'AR', 'America/Jujuy', 'Argentina Standard Time', 'es-AR', ('America/Jujuy', 'America/Argentina/Jujuy')), + ('ar-america-mendoza', '阿根廷 · 门多萨', 'AR', 'America/Mendoza', 'Argentina Standard Time', 'es-AR', ('America/Mendoza', 'America/Argentina/Mendoza')), + ('as-pacific-pago-pago', '美属萨摩亚 · 帕果帕果', 'AS', 'Pacific/Pago_Pago', 'UTC-11', 'sm-AS', ('Pacific/Pago_Pago', 'Pacific/Samoa', 'US/Samoa')), + ('at-europe-vienna', '奥地利 · 维也纳', 'AT', 'Europe/Vienna', 'W. Europe Standard Time', 'de-AT', ('Europe/Vienna',)), + ('au-antarctica-macquarie', '澳大利亚 · 麦夸里岛', 'AU', 'Antarctica/Macquarie', 'Tasmania Standard Time', '', ('Antarctica/Macquarie',)), + ('au-australia-adelaide', '澳大利亚 · 阿德莱德', 'AU', 'Australia/Adelaide', 'Cen. Australia Standard Time', '', ('Australia/Adelaide', 'Australia/South')), + ('au-australia-brisbane', '澳大利亚 · 布里斯班', 'AU', 'Australia/Brisbane', 'E. Australia Standard Time', '', ('Australia/Brisbane', 'Australia/Queensland')), + ('au-australia-broken-hill', '澳大利亚 · 布罗肯希尔', 'AU', 'Australia/Broken_Hill', 'Cen. Australia Standard Time', '', ('Australia/Broken_Hill', 'Australia/Yancowinna')), + ('au-australia-darwin', '澳大利亚 · 达尔文', 'AU', 'Australia/Darwin', 'AUS Central Standard Time', '', ('Australia/Darwin', 'Australia/North')), + ('au-australia-eucla', '澳大利亚 · 尤克拉', 'AU', 'Australia/Eucla', 'Aus Central W. Standard Time', '', ('Australia/Eucla',)), + ('au-australia-hobart', '澳大利亚 · 霍巴特', 'AU', 'Australia/Hobart', 'Tasmania Standard Time', '', ('Australia/Hobart', 'Australia/Tasmania', 'Australia/Currie')), + ('au-australia-lindeman', '澳大利亚 · 林德曼', 'AU', 'Australia/Lindeman', 'E. Australia Standard Time', '', ('Australia/Lindeman',)), + ('au-australia-lord-howe', '澳大利亚 · 豪勋爵岛', 'AU', 'Australia/Lord_Howe', 'Lord Howe Standard Time', '', ('Australia/Lord_Howe', 'Australia/LHI')), + ('au-australia-melbourne', '澳大利亚 · 墨尔本', 'AU', 'Australia/Melbourne', 'AUS Eastern Standard Time', '', ('Australia/Melbourne', 'Australia/Victoria')), + ('au-australia-perth', '澳大利亚 · 珀斯', 'AU', 'Australia/Perth', 'W. Australia Standard Time', '', ('Australia/Perth', 'Australia/West')), + ('au-australia-sydney', '澳大利亚 · 悉尼', 'AU', 'Australia/Sydney', 'AUS Eastern Standard Time', '', ('Australia/Sydney', 'Australia/ACT', 'Australia/Canberra', 'Australia/NSW')), + ('aw-america-aruba', '阿鲁巴 · 阿鲁巴', 'AW', 'America/Aruba', 'SA Western Standard Time', 'nl-AW', ('America/Aruba',)), + ('ax-europe-mariehamn', '奥兰群岛 · 玛丽港', 'AX', 'Europe/Mariehamn', 'FLE Standard Time', 'sv-AX', ('Europe/Mariehamn',)), + ('az-asia-baku', '阿塞拜疆 · 巴库', 'AZ', 'Asia/Baku', 'Azerbaijan Standard Time', 'az-AZ', ('Asia/Baku',)), + ('ba-europe-sarajevo', '波斯尼亚和黑塞哥维那 · 萨拉热窝', 'BA', 'Europe/Sarajevo', 'Central European Standard Time', 'bs-BA', ('Europe/Sarajevo',)), + ('bb-america-barbados', '巴巴多斯 · 巴巴多斯', 'BB', 'America/Barbados', 'SA Western Standard Time', '', ('America/Barbados',)), + ('bd-asia-dhaka', '孟加拉国 · 达卡', 'BD', 'Asia/Dhaka', 'Bangladesh Standard Time', 'bn-BD', ('Asia/Dhaka', 'Asia/Dacca')), + ('be-europe-brussels', '比利时 · 布鲁塞尔', 'BE', 'Europe/Brussels', 'Romance Standard Time', 'nl-BE', ('Europe/Brussels', 'CET', 'MET')), + ('bf-africa-ouagadougou', '布基纳法索 · 瓦加杜古', 'BF', 'Africa/Ouagadougou', 'Greenwich Standard Time', 'fr-BF', ('Africa/Ouagadougou',)), + ('bg-europe-sofia', '保加利亚 · 索非亚', 'BG', 'Europe/Sofia', 'FLE Standard Time', 'bg-BG', ('Europe/Sofia',)), + ('bh-asia-bahrain', '巴林 · 巴林', 'BH', 'Asia/Bahrain', 'Arab Standard Time', 'ar-BH', ('Asia/Bahrain',)), + ('bi-africa-bujumbura', '布隆迪 · 布琼布拉', 'BI', 'Africa/Bujumbura', 'South Africa Standard Time', 'rn-BI', ('Africa/Bujumbura',)), + ('bj-africa-porto-novo', '贝宁 · 波多诺伏', 'BJ', 'Africa/Porto-Novo', 'W. Central Africa Standard Time', 'fr-BJ', ('Africa/Porto-Novo',)), + ('bl-america-st-barthelemy', '圣巴泰勒米 · 圣巴泰勒米岛', 'BL', 'America/St_Barthelemy', 'SA Western Standard Time', 'fr-BL', ('America/St_Barthelemy',)), + ('bm-atlantic-bermuda', '百慕大 · 百慕大', 'BM', 'Atlantic/Bermuda', 'Atlantic Standard Time', '', ('Atlantic/Bermuda',)), + ('bn-asia-brunei', '文莱 · 文莱', 'BN', 'Asia/Brunei', 'Singapore Standard Time', 'ms-BN', ('Asia/Brunei',)), + ('bo-america-la-paz', '玻利维亚 · 拉巴斯', 'BO', 'America/La_Paz', 'SA Western Standard Time', 'es-BO', ('America/La_Paz',)), + ('bq-america-kralendijk', '荷属加勒比区 · 克拉伦代克', 'BQ', 'America/Kralendijk', 'SA Western Standard Time', 'pap-BQ', ('America/Kralendijk',)), + ('br-america-araguaina', '巴西 · 阿拉瓜伊纳', 'BR', 'America/Araguaina', 'Tocantins Standard Time', 'pt-BR', ('America/Araguaina',)), + ('br-america-bahia', '巴西 · 巴伊亚', 'BR', 'America/Bahia', 'Bahia Standard Time', 'pt-BR', ('America/Bahia',)), + ('br-america-belem', '巴西 · 贝伦', 'BR', 'America/Belem', 'SA Eastern Standard Time', 'pt-BR', ('America/Belem',)), + ('br-america-boa-vista', '巴西 · 博阿维斯塔', 'BR', 'America/Boa_Vista', 'SA Western Standard Time', 'pt-BR', ('America/Boa_Vista',)), + ('br-america-campo-grande', '巴西 · 大坎普', 'BR', 'America/Campo_Grande', 'Central Brazilian Standard Time', 'pt-BR', ('America/Campo_Grande',)), + ('br-america-cuiaba', '巴西 · 库亚巴', 'BR', 'America/Cuiaba', 'Central Brazilian Standard Time', 'pt-BR', ('America/Cuiaba',)), + ('br-america-eirunepe', '巴西 · 依伦尼贝', 'BR', 'America/Eirunepe', 'SA Pacific Standard Time', 'pt-BR', ('America/Eirunepe',)), + ('br-america-fortaleza', '巴西 · 福塔雷萨', 'BR', 'America/Fortaleza', 'SA Eastern Standard Time', 'pt-BR', ('America/Fortaleza',)), + ('br-america-maceio', '巴西 · 马塞约', 'BR', 'America/Maceio', 'SA Eastern Standard Time', 'pt-BR', ('America/Maceio',)), + ('br-america-manaus', '巴西 · 马瑙斯', 'BR', 'America/Manaus', 'SA Western Standard Time', 'pt-BR', ('America/Manaus', 'Brazil/West')), + ('br-america-noronha', '巴西 · 洛罗尼亚', 'BR', 'America/Noronha', 'UTC-02', 'pt-BR', ('America/Noronha', 'Brazil/DeNoronha')), + ('br-america-porto-velho', '巴西 · 波多韦柳', 'BR', 'America/Porto_Velho', 'SA Western Standard Time', 'pt-BR', ('America/Porto_Velho',)), + ('br-america-recife', '巴西 · 累西腓', 'BR', 'America/Recife', 'SA Eastern Standard Time', 'pt-BR', ('America/Recife',)), + ('br-america-rio-branco', '巴西 · 里奥布郎库', 'BR', 'America/Rio_Branco', 'SA Pacific Standard Time', 'pt-BR', ('America/Rio_Branco', 'America/Porto_Acre', 'Brazil/Acre')), + ('br-america-santarem', '巴西 · 圣塔伦', 'BR', 'America/Santarem', 'SA Eastern Standard Time', 'pt-BR', ('America/Santarem',)), + ('br-america-sao-paulo', '巴西 · 圣保罗', 'BR', 'America/Sao_Paulo', 'E. South America Standard Time', 'pt-BR', ('America/Sao_Paulo', 'Brazil/East')), + ('bs-america-nassau', '巴哈马 · 拿骚', 'BS', 'America/Nassau', 'Eastern Standard Time', '', ('America/Nassau',)), + ('bt-asia-thimphu', '不丹 · 廷布', 'BT', 'Asia/Thimphu', 'Bangladesh Standard Time', 'dz-BT', ('Asia/Thimphu', 'Asia/Thimbu')), + ('bw-africa-gaborone', '博茨瓦纳 · 哈博罗内', 'BW', 'Africa/Gaborone', 'South Africa Standard Time', '', ('Africa/Gaborone',)), + ('by-europe-minsk', '白俄罗斯 · 明斯克', 'BY', 'Europe/Minsk', 'Belarus Standard Time', 'ru-BY', ('Europe/Minsk',)), + ('bz-america-belize', '伯利兹 · 伯利兹', 'BZ', 'America/Belize', 'Central America Standard Time', '', ('America/Belize',)), + ('ca-america-blanc-sablon', '加拿大 · 布兰克萨布隆', 'CA', 'America/Blanc-Sablon', 'SA Western Standard Time', '', ('America/Blanc-Sablon',)), + ('ca-america-cambridge-bay', '加拿大 · 剑桥湾', 'CA', 'America/Cambridge_Bay', 'Mountain Standard Time', '', ('America/Cambridge_Bay',)), + ('ca-america-coral-harbour', '加拿大 · 阿蒂科肯', 'CA', 'America/Coral_Harbour', 'SA Pacific Standard Time', '', ('America/Coral_Harbour', 'America/Atikokan')), + ('ca-america-creston', '加拿大 · 克雷斯顿', 'CA', 'America/Creston', 'US Mountain Standard Time', '', ('America/Creston',)), + ('ca-america-dawson', '加拿大 · 道森', 'CA', 'America/Dawson', 'Yukon Standard Time', '', ('America/Dawson',)), + ('ca-america-dawson-creek', '加拿大 · 道森克里克', 'CA', 'America/Dawson_Creek', 'US Mountain Standard Time', '', ('America/Dawson_Creek',)), + ('ca-america-edmonton', '加拿大 · 埃德蒙顿', 'CA', 'America/Edmonton', 'Mountain Standard Time', '', ('America/Edmonton', 'Canada/Mountain', 'America/Yellowknife')), + ('ca-america-fort-nelson', '加拿大 · 纳尔逊堡', 'CA', 'America/Fort_Nelson', 'US Mountain Standard Time', '', ('America/Fort_Nelson',)), + ('ca-america-glace-bay', '加拿大 · 格莱斯贝', 'CA', 'America/Glace_Bay', 'Atlantic Standard Time', '', ('America/Glace_Bay',)), + ('ca-america-goose-bay', '加拿大 · 古斯湾', 'CA', 'America/Goose_Bay', 'Atlantic Standard Time', '', ('America/Goose_Bay',)), + ('ca-america-halifax', '加拿大 · 哈利法克斯', 'CA', 'America/Halifax', 'Atlantic Standard Time', '', ('America/Halifax', 'Canada/Atlantic')), + ('ca-america-inuvik', '加拿大 · 伊努维克', 'CA', 'America/Inuvik', 'Mountain Standard Time', '', ('America/Inuvik',)), + ('ca-america-iqaluit', '加拿大 · 伊魁特', 'CA', 'America/Iqaluit', 'Eastern Standard Time', '', ('America/Iqaluit', 'America/Pangnirtung')), + ('ca-america-moncton', '加拿大 · 蒙克顿', 'CA', 'America/Moncton', 'Atlantic Standard Time', '', ('America/Moncton',)), + ('ca-america-rankin-inlet', '加拿大 · 兰今湾', 'CA', 'America/Rankin_Inlet', 'Central Standard Time', '', ('America/Rankin_Inlet',)), + ('ca-america-regina', '加拿大 · 里贾纳', 'CA', 'America/Regina', 'Canada Central Standard Time', '', ('America/Regina', 'Canada/East-Saskatchewan', 'Canada/Saskatchewan')), + ('ca-america-resolute', '加拿大 · 雷索卢特', 'CA', 'America/Resolute', 'Central Standard Time', '', ('America/Resolute',)), + ('ca-america-st-johns', '加拿大 · 圣约翰斯', 'CA', 'America/St_Johns', 'Newfoundland Standard Time', '', ('America/St_Johns', 'Canada/Newfoundland')), + ('ca-america-swift-current', '加拿大 · 斯威夫特卡伦特', 'CA', 'America/Swift_Current', 'Canada Central Standard Time', '', ('America/Swift_Current',)), + ('ca-america-toronto', '加拿大 · 多伦多', 'CA', 'America/Toronto', 'Eastern Standard Time', '', ('America/Toronto', 'America/Montreal', 'Canada/Eastern', 'America/Nipigon', 'America/Thunder_Bay')), + ('ca-america-vancouver', '加拿大 · 温哥华', 'CA', 'America/Vancouver', 'Pacific Standard Time', '', ('America/Vancouver', 'Canada/Pacific')), + ('ca-america-whitehorse', '加拿大 · 怀特霍斯', 'CA', 'America/Whitehorse', 'Yukon Standard Time', '', ('America/Whitehorse', 'Canada/Yukon')), + ('ca-america-winnipeg', '加拿大 · 温尼伯', 'CA', 'America/Winnipeg', 'Central Standard Time', '', ('America/Winnipeg', 'Canada/Central', 'America/Rainy_River')), + ('cc-indian-cocos', '科科斯(基林)群岛 · 可可斯', 'CC', 'Indian/Cocos', 'Myanmar Standard Time', 'ms-CC', ('Indian/Cocos',)), + ('cd-africa-kinshasa', '刚果(金) · 金沙萨', 'CD', 'Africa/Kinshasa', 'W. Central Africa Standard Time', 'fr-CD', ('Africa/Kinshasa',)), + ('cd-africa-lubumbashi', '刚果(金) · 卢本巴希', 'CD', 'Africa/Lubumbashi', 'South Africa Standard Time', 'fr-CD', ('Africa/Lubumbashi',)), + ('cf-africa-bangui', '中非共和国 · 班吉', 'CF', 'Africa/Bangui', 'W. Central Africa Standard Time', 'sg-CF', ('Africa/Bangui',)), + ('cg-africa-brazzaville', '刚果(布) · 布拉柴维尔', 'CG', 'Africa/Brazzaville', 'W. Central Africa Standard Time', 'fr-CG', ('Africa/Brazzaville',)), + ('ch-europe-zurich', '瑞士 · 苏黎世', 'CH', 'Europe/Zurich', 'W. Europe Standard Time', 'de-CH', ('Europe/Zurich',)), + ('ci-africa-abidjan', '科特迪瓦 · 阿比让', 'CI', 'Africa/Abidjan', 'Greenwich Standard Time', 'fr-CI', ('Africa/Abidjan',)), + ('ck-pacific-rarotonga', '库克群岛 · 拉罗汤加', 'CK', 'Pacific/Rarotonga', 'Hawaiian Standard Time', '', ('Pacific/Rarotonga',)), + ('cl-america-coyhaique', '智利 · 科伊艾克', 'CL', 'America/Coyhaique', 'Magallanes Standard Time', 'es-CL', ('America/Coyhaique',)), + ('cl-america-punta-arenas', '智利 · 蓬塔阿雷纳斯', 'CL', 'America/Punta_Arenas', 'Magallanes Standard Time', 'es-CL', ('America/Punta_Arenas',)), + ('cl-america-santiago', '智利 · 圣地亚哥', 'CL', 'America/Santiago', 'Pacific SA Standard Time', 'es-CL', ('America/Santiago', 'Chile/Continental')), + ('cl-pacific-easter', '智利 · 复活节岛', 'CL', 'Pacific/Easter', 'Easter Island Standard Time', 'es-CL', ('Pacific/Easter', 'Chile/EasterIsland')), + ('cm-africa-douala', '喀麦隆 · 杜阿拉', 'CM', 'Africa/Douala', 'W. Central Africa Standard Time', 'fr-CM', ('Africa/Douala',)), + ('cn-asia-shanghai', '中国大陆 · 上海', 'CN', 'Asia/Shanghai', 'China Standard Time', 'zh-CN', ('Asia/Shanghai', 'Asia/Chongqing', 'Asia/Chungking', 'Asia/Harbin', 'PRC')), + ('cn-asia-urumqi', '中国大陆 · 乌鲁木齐', 'CN', 'Asia/Urumqi', 'Central Asia Standard Time', 'zh-CN', ('Asia/Urumqi', 'Asia/Kashgar')), + ('co-america-bogota', '哥伦比亚 · 波哥大', 'CO', 'America/Bogota', 'SA Pacific Standard Time', 'es-CO', ('America/Bogota',)), + ('cr-america-costa-rica', '哥斯达黎加 · 哥斯达黎加', 'CR', 'America/Costa_Rica', 'Central America Standard Time', 'es-CR', ('America/Costa_Rica',)), + ('cu-america-havana', '古巴 · 哈瓦那', 'CU', 'America/Havana', 'Cuba Standard Time', 'es-CU', ('America/Havana', 'Cuba')), + ('cv-atlantic-cape-verde', '佛得角 · 佛得角', 'CV', 'Atlantic/Cape_Verde', 'Cape Verde Standard Time', 'pt-CV', ('Atlantic/Cape_Verde',)), + ('cw-america-curacao', '库拉索 · 库拉索', 'CW', 'America/Curacao', 'SA Western Standard Time', 'pap-CW', ('America/Curacao',)), + ('cx-indian-christmas', '圣诞岛 · 圣诞岛', 'CX', 'Indian/Christmas', 'SE Asia Standard Time', '', ('Indian/Christmas',)), + ('cy-asia-famagusta', '塞浦路斯 · 法马古斯塔', 'CY', 'Asia/Famagusta', 'GTB Standard Time', 'el-CY', ('Asia/Famagusta',)), + ('cy-asia-nicosia', '塞浦路斯 · 尼科西亚', 'CY', 'Asia/Nicosia', 'GTB Standard Time', 'el-CY', ('Asia/Nicosia', 'Europe/Nicosia')), + ('cz-europe-prague', '捷克 · 布拉格', 'CZ', 'Europe/Prague', 'Central Europe Standard Time', 'cs-CZ', ('Europe/Prague',)), + ('de-europe-berlin', '德国 · 柏林', 'DE', 'Europe/Berlin', 'W. Europe Standard Time', 'de-DE', ('Europe/Berlin',)), + ('de-europe-busingen', '德国 · 布辛根', 'DE', 'Europe/Busingen', 'W. Europe Standard Time', 'de-DE', ('Europe/Busingen',)), + ('dj-africa-djibouti', '吉布提 · 吉布提', 'DJ', 'Africa/Djibouti', 'E. Africa Standard Time', 'fr-DJ', ('Africa/Djibouti',)), + ('dk-europe-copenhagen', '丹麦 · 哥本哈根', 'DK', 'Europe/Copenhagen', 'Romance Standard Time', 'da-DK', ('Europe/Copenhagen',)), + ('dm-america-dominica', '多米尼克 · 多米尼加', 'DM', 'America/Dominica', 'SA Western Standard Time', '', ('America/Dominica',)), + ('do-america-santo-domingo', '多米尼加共和国 · 圣多明各', 'DO', 'America/Santo_Domingo', 'SA Western Standard Time', 'es-DO', ('America/Santo_Domingo',)), + ('dz-africa-algiers', '阿尔及利亚 · 阿尔及尔', 'DZ', 'Africa/Algiers', 'W. Central Africa Standard Time', 'ar-DZ', ('Africa/Algiers',)), + ('ec-america-guayaquil', '厄瓜多尔 · 瓜亚基尔', 'EC', 'America/Guayaquil', 'SA Pacific Standard Time', 'es-EC', ('America/Guayaquil',)), + ('ec-pacific-galapagos', '厄瓜多尔 · 科隆群岛', 'EC', 'Pacific/Galapagos', 'Central America Standard Time', 'es-EC', ('Pacific/Galapagos',)), + ('ee-europe-tallinn', '爱沙尼亚 · 塔林', 'EE', 'Europe/Tallinn', 'FLE Standard Time', 'et-EE', ('Europe/Tallinn',)), + ('eg-africa-cairo', '埃及 · 开罗', 'EG', 'Africa/Cairo', 'Egypt Standard Time', 'ar-EG', ('Africa/Cairo', 'Egypt')), + ('eh-africa-el-aaiun', '西撒哈拉 · 阿尤恩', 'EH', 'Africa/El_Aaiun', 'Morocco Standard Time', 'ar-EH', ('Africa/El_Aaiun',)), + ('er-africa-asmera', '厄立特里亚 · 阿斯马拉', 'ER', 'Africa/Asmera', 'E. Africa Standard Time', 'ti-ER', ('Africa/Asmera', 'Africa/Asmara')), + ('es-africa-ceuta', '西班牙 · 休达', 'ES', 'Africa/Ceuta', 'Romance Standard Time', 'es-ES', ('Africa/Ceuta',)), + ('es-atlantic-canary', '西班牙 · 加那利', 'ES', 'Atlantic/Canary', 'GMT Standard Time', 'es-ES', ('Atlantic/Canary',)), + ('es-europe-madrid', '西班牙 · 马德里', 'ES', 'Europe/Madrid', 'Romance Standard Time', 'es-ES', ('Europe/Madrid',)), + ('et-africa-addis-ababa', '埃塞俄比亚 · 亚的斯亚贝巴', 'ET', 'Africa/Addis_Ababa', 'E. Africa Standard Time', 'am-ET', ('Africa/Addis_Ababa',)), + ('fi-europe-helsinki', '芬兰 · 赫尔辛基', 'FI', 'Europe/Helsinki', 'FLE Standard Time', 'fi-FI', ('Europe/Helsinki',)), + ('fj-pacific-fiji', '斐济 · 斐济', 'FJ', 'Pacific/Fiji', 'Fiji Standard Time', '', ('Pacific/Fiji',)), + ('fk-atlantic-stanley', '福克兰群岛 · 斯坦利', 'FK', 'Atlantic/Stanley', 'SA Eastern Standard Time', '', ('Atlantic/Stanley',)), + ('fm-pacific-kosrae', '密克罗尼西亚 · 库赛埃', 'FM', 'Pacific/Kosrae', 'Central Pacific Standard Time', '', ('Pacific/Kosrae',)), + ('fm-pacific-ponape', '密克罗尼西亚 · 波纳佩岛', 'FM', 'Pacific/Ponape', 'Central Pacific Standard Time', '', ('Pacific/Ponape', 'Pacific/Pohnpei')), + ('fm-pacific-truk', '密克罗尼西亚 · 特鲁克群岛', 'FM', 'Pacific/Truk', 'West Pacific Standard Time', '', ('Pacific/Truk', 'Pacific/Chuuk', 'Pacific/Yap')), + ('fo-atlantic-faeroe', '法罗群岛 · 法罗', 'FO', 'Atlantic/Faeroe', 'GMT Standard Time', 'fo-FO', ('Atlantic/Faeroe', 'Atlantic/Faroe')), + ('fr-europe-paris', '法国 · 巴黎', 'FR', 'Europe/Paris', 'Romance Standard Time', 'fr-FR', ('Europe/Paris',)), + ('ga-africa-libreville', '加蓬 · 利伯维尔', 'GA', 'Africa/Libreville', 'W. Central Africa Standard Time', 'fr-GA', ('Africa/Libreville',)), + ('gb-europe-london', '英国 · 伦敦', 'GB', 'Europe/London', 'GMT Standard Time', '', ('Europe/London', 'Europe/Belfast', 'GB', 'GB-Eire')), + ('gd-america-grenada', '格林纳达 · 格林纳达', 'GD', 'America/Grenada', 'SA Western Standard Time', '', ('America/Grenada',)), + ('ge-asia-tbilisi', '格鲁吉亚 · 第比利斯', 'GE', 'Asia/Tbilisi', 'Georgian Standard Time', 'ka-GE', ('Asia/Tbilisi',)), + ('gf-america-cayenne', '法属圭亚那 · 卡宴', 'GF', 'America/Cayenne', 'SA Eastern Standard Time', 'fr-GF', ('America/Cayenne',)), + ('gg-europe-guernsey', '根西岛 · 根西岛', 'GG', 'Europe/Guernsey', 'GMT Standard Time', '', ('Europe/Guernsey',)), + ('gh-africa-accra', '加纳 · 阿克拉', 'GH', 'Africa/Accra', 'Greenwich Standard Time', 'ak-GH', ('Africa/Accra',)), + ('gi-europe-gibraltar', '直布罗陀 · 直布罗陀', 'GI', 'Europe/Gibraltar', 'W. Europe Standard Time', '', ('Europe/Gibraltar',)), + ('gl-america-danmarkshavn', '格陵兰 · 丹马沙文', 'GL', 'America/Danmarkshavn', 'Greenwich Standard Time', 'kl-GL', ('America/Danmarkshavn',)), + ('gl-america-godthab', '格陵兰 · 努克', 'GL', 'America/Godthab', 'Greenland Standard Time', 'kl-GL', ('America/Godthab', 'America/Nuuk')), + ('gl-america-scoresbysund', '格陵兰 · 斯科列斯比桑德', 'GL', 'America/Scoresbysund', 'Azores Standard Time', 'kl-GL', ('America/Scoresbysund',)), + ('gl-america-thule', '格陵兰 · 图勒', 'GL', 'America/Thule', 'Atlantic Standard Time', 'kl-GL', ('America/Thule',)), + ('gm-africa-banjul', '冈比亚 · 班珠尔', 'GM', 'Africa/Banjul', 'Greenwich Standard Time', '', ('Africa/Banjul',)), + ('gn-africa-conakry', '几内亚 · 科纳克里', 'GN', 'Africa/Conakry', 'Greenwich Standard Time', 'fr-GN', ('Africa/Conakry',)), + ('gp-america-guadeloupe', '瓜德罗普 · 瓜德罗普', 'GP', 'America/Guadeloupe', 'SA Western Standard Time', 'fr-GP', ('America/Guadeloupe',)), + ('gq-africa-malabo', '赤道几内亚 · 马拉博', 'GQ', 'Africa/Malabo', 'W. Central Africa Standard Time', 'es-GQ', ('Africa/Malabo',)), + ('gr-europe-athens', '希腊 · 雅典', 'GR', 'Europe/Athens', 'GTB Standard Time', 'el-GR', ('Europe/Athens', 'EET')), + ('gs-atlantic-south-georgia', '南乔治亚和南桑威奇群岛 · 南乔治亚', 'GS', 'Atlantic/South_Georgia', 'UTC-02', '', ('Atlantic/South_Georgia',)), + ('gt-america-guatemala', '危地马拉 · 危地马拉', 'GT', 'America/Guatemala', 'Central America Standard Time', 'es-GT', ('America/Guatemala',)), + ('gu-pacific-guam', '关岛 · 关岛', 'GU', 'Pacific/Guam', 'West Pacific Standard Time', '', ('Pacific/Guam',)), + ('gw-africa-bissau', '几内亚比绍 · 比绍', 'GW', 'Africa/Bissau', 'Greenwich Standard Time', 'pt-GW', ('Africa/Bissau',)), + ('gy-america-guyana', '圭亚那 · 圭亚那', 'GY', 'America/Guyana', 'SA Western Standard Time', '', ('America/Guyana',)), + ('hk-asia-hong-kong', '中国香港 · 香港', 'HK', 'Asia/Hong_Kong', 'China Standard Time', 'zh-HK', ('Asia/Hong_Kong', 'Hongkong')), + ('hn-america-tegucigalpa', '洪都拉斯 · 特古西加尔巴', 'HN', 'America/Tegucigalpa', 'Central America Standard Time', 'es-HN', ('America/Tegucigalpa',)), + ('hr-europe-zagreb', '克罗地亚 · 萨格勒布', 'HR', 'Europe/Zagreb', 'Central European Standard Time', 'hr-HR', ('Europe/Zagreb',)), + ('ht-america-port-au-prince', '海地 · 太子港', 'HT', 'America/Port-au-Prince', 'Haiti Standard Time', 'ht-HT', ('America/Port-au-Prince',)), + ('hu-europe-budapest', '匈牙利 · 布达佩斯', 'HU', 'Europe/Budapest', 'Central Europe Standard Time', 'hu-HU', ('Europe/Budapest',)), + ('id-asia-jakarta', '印度尼西亚 · 雅加达', 'ID', 'Asia/Jakarta', 'SE Asia Standard Time', 'id-ID', ('Asia/Jakarta',)), + ('id-asia-jayapura', '印度尼西亚 · 查亚普拉', 'ID', 'Asia/Jayapura', 'Tokyo Standard Time', 'id-ID', ('Asia/Jayapura',)), + ('id-asia-makassar', '印度尼西亚 · 望加锡', 'ID', 'Asia/Makassar', 'Singapore Standard Time', 'id-ID', ('Asia/Makassar', 'Asia/Ujung_Pandang')), + ('id-asia-pontianak', '印度尼西亚 · 坤甸', 'ID', 'Asia/Pontianak', 'SE Asia Standard Time', 'id-ID', ('Asia/Pontianak',)), + ('ie-europe-dublin', '爱尔兰 · 都柏林', 'IE', 'Europe/Dublin', 'GMT Standard Time', '', ('Europe/Dublin', 'Eire')), + ('il-asia-jerusalem', '以色列 · 耶路撒冷', 'IL', 'Asia/Jerusalem', 'Israel Standard Time', 'he-IL', ('Asia/Jerusalem', 'Asia/Tel_Aviv', 'Israel')), + ('im-europe-isle-of-man', '马恩岛 · 马恩岛', 'IM', 'Europe/Isle_of_Man', 'GMT Standard Time', '', ('Europe/Isle_of_Man',)), + ('in-asia-calcutta', '印度 · 加尔各答', 'IN', 'Asia/Calcutta', 'India Standard Time', 'hi-IN', ('Asia/Calcutta', 'Asia/Kolkata')), + ('io-indian-chagos', '英属印度洋领地 · 查戈斯', 'IO', 'Indian/Chagos', 'Central Asia Standard Time', '', ('Indian/Chagos',)), + ('iq-asia-baghdad', '伊拉克 · 巴格达', 'IQ', 'Asia/Baghdad', 'Arabic Standard Time', 'ar-IQ', ('Asia/Baghdad',)), + ('ir-asia-tehran', '伊朗 · 德黑兰', 'IR', 'Asia/Tehran', 'Iran Standard Time', 'fa-IR', ('Asia/Tehran', 'Iran')), + ('is-atlantic-reykjavik', '冰岛 · 雷克雅未克', 'IS', 'Atlantic/Reykjavik', 'Greenwich Standard Time', 'is-IS', ('Atlantic/Reykjavik', 'Iceland')), + ('it-europe-rome', '意大利 · 罗马', 'IT', 'Europe/Rome', 'W. Europe Standard Time', 'it-IT', ('Europe/Rome',)), + ('je-europe-jersey', '泽西岛 · 泽西岛', 'JE', 'Europe/Jersey', 'GMT Standard Time', '', ('Europe/Jersey',)), + ('jm-america-jamaica', '牙买加 · 牙买加', 'JM', 'America/Jamaica', 'SA Pacific Standard Time', '', ('America/Jamaica', 'Jamaica')), + ('jo-asia-amman', '约旦 · 安曼', 'JO', 'Asia/Amman', 'Jordan Standard Time', 'ar-JO', ('Asia/Amman',)), + ('jp-asia-tokyo', '日本 · 东京', 'JP', 'Asia/Tokyo', 'Tokyo Standard Time', 'ja-JP', ('Asia/Tokyo', 'Japan')), + ('ke-africa-nairobi', '肯尼亚 · 内罗毕', 'KE', 'Africa/Nairobi', 'E. Africa Standard Time', 'sw-KE', ('Africa/Nairobi',)), + ('kg-asia-bishkek', '吉尔吉斯斯坦 · 比什凯克', 'KG', 'Asia/Bishkek', 'Central Asia Standard Time', 'ky-KG', ('Asia/Bishkek',)), + ('kh-asia-phnom-penh', '柬埔寨 · 金边', 'KH', 'Asia/Phnom_Penh', 'SE Asia Standard Time', 'km-KH', ('Asia/Phnom_Penh',)), + ('ki-pacific-enderbury', '基里巴斯 · 坎顿岛', 'KI', 'Pacific/Enderbury', 'UTC+13', '', ('Pacific/Enderbury', 'Pacific/Kanton')), + ('ki-pacific-kiritimati', '基里巴斯 · 基里地马地岛', 'KI', 'Pacific/Kiritimati', 'Line Islands Standard Time', '', ('Pacific/Kiritimati',)), + ('ki-pacific-tarawa', '基里巴斯 · 塔拉瓦', 'KI', 'Pacific/Tarawa', 'UTC+12', '', ('Pacific/Tarawa',)), + ('km-indian-comoro', '科摩罗 · 科摩罗', 'KM', 'Indian/Comoro', 'E. Africa Standard Time', 'ar-KM', ('Indian/Comoro',)), + ('kn-america-st-kitts', '圣基茨和尼维斯 · 圣基茨', 'KN', 'America/St_Kitts', 'SA Western Standard Time', '', ('America/St_Kitts',)), + ('kp-asia-pyongyang', '朝鲜 · 平壤', 'KP', 'Asia/Pyongyang', 'North Korea Standard Time', 'ko-KP', ('Asia/Pyongyang',)), + ('kr-asia-seoul', '韩国 · 首尔', 'KR', 'Asia/Seoul', 'Korea Standard Time', 'ko-KR', ('Asia/Seoul', 'ROK')), + ('kw-asia-kuwait', '科威特 · 科威特', 'KW', 'Asia/Kuwait', 'Arab Standard Time', 'ar-KW', ('Asia/Kuwait',)), + ('ky-america-cayman', '开曼群岛 · 开曼', 'KY', 'America/Cayman', 'SA Pacific Standard Time', '', ('America/Cayman',)), + ('kz-asia-almaty', '哈萨克斯坦 · 阿拉木图', 'KZ', 'Asia/Almaty', 'West Asia Standard Time', 'ru-KZ', ('Asia/Almaty',)), + ('kz-asia-aqtau', '哈萨克斯坦 · 阿克套', 'KZ', 'Asia/Aqtau', 'West Asia Standard Time', 'ru-KZ', ('Asia/Aqtau',)), + ('kz-asia-aqtobe', '哈萨克斯坦 · 阿克托别', 'KZ', 'Asia/Aqtobe', 'West Asia Standard Time', 'ru-KZ', ('Asia/Aqtobe',)), + ('kz-asia-atyrau', '哈萨克斯坦 · 阿特劳', 'KZ', 'Asia/Atyrau', 'West Asia Standard Time', 'ru-KZ', ('Asia/Atyrau',)), + ('kz-asia-oral', '哈萨克斯坦 · 乌拉尔', 'KZ', 'Asia/Oral', 'West Asia Standard Time', 'ru-KZ', ('Asia/Oral',)), + ('kz-asia-qostanay', '哈萨克斯坦 · 库斯塔奈', 'KZ', 'Asia/Qostanay', 'West Asia Standard Time', 'ru-KZ', ('Asia/Qostanay',)), + ('kz-asia-qyzylorda', '哈萨克斯坦 · 克孜洛尔达', 'KZ', 'Asia/Qyzylorda', 'Qyzylorda Standard Time', 'ru-KZ', ('Asia/Qyzylorda',)), + ('la-asia-vientiane', '老挝 · 万象', 'LA', 'Asia/Vientiane', 'SE Asia Standard Time', 'lo-LA', ('Asia/Vientiane',)), + ('lb-asia-beirut', '黎巴嫩 · 贝鲁特', 'LB', 'Asia/Beirut', 'Middle East Standard Time', 'ar-LB', ('Asia/Beirut',)), + ('lc-america-st-lucia', '圣卢西亚 · 圣卢西亚', 'LC', 'America/St_Lucia', 'SA Western Standard Time', '', ('America/St_Lucia',)), + ('li-europe-vaduz', '列支敦士登 · 瓦杜兹', 'LI', 'Europe/Vaduz', 'W. Europe Standard Time', 'de-LI', ('Europe/Vaduz',)), + ('lk-asia-colombo', '斯里兰卡 · 科伦坡', 'LK', 'Asia/Colombo', 'Sri Lanka Standard Time', 'si-LK', ('Asia/Colombo',)), + ('lr-africa-monrovia', '利比里亚 · 蒙罗维亚', 'LR', 'Africa/Monrovia', 'Greenwich Standard Time', '', ('Africa/Monrovia',)), + ('ls-africa-maseru', '莱索托 · 马塞卢', 'LS', 'Africa/Maseru', 'South Africa Standard Time', 'st-LS', ('Africa/Maseru',)), + ('lt-europe-vilnius', '立陶宛 · 维尔纽斯', 'LT', 'Europe/Vilnius', 'FLE Standard Time', 'lt-LT', ('Europe/Vilnius',)), + ('lu-europe-luxembourg', '卢森堡 · 卢森堡', 'LU', 'Europe/Luxembourg', 'W. Europe Standard Time', 'fr-LU', ('Europe/Luxembourg',)), + ('lv-europe-riga', '拉脱维亚 · 里加', 'LV', 'Europe/Riga', 'FLE Standard Time', 'lv-LV', ('Europe/Riga',)), + ('ly-africa-tripoli', '利比亚 · 的黎波里', 'LY', 'Africa/Tripoli', 'Libya Standard Time', 'ar-LY', ('Africa/Tripoli', 'Libya')), + ('ma-africa-casablanca', '摩洛哥 · 卡萨布兰卡', 'MA', 'Africa/Casablanca', 'Morocco Standard Time', 'ar-MA', ('Africa/Casablanca',)), + ('mc-europe-monaco', '摩纳哥 · 摩纳哥', 'MC', 'Europe/Monaco', 'W. Europe Standard Time', 'fr-MC', ('Europe/Monaco',)), + ('md-europe-chisinau', '摩尔多瓦 · 基希讷乌', 'MD', 'Europe/Chisinau', 'E. Europe Standard Time', 'ro-MD', ('Europe/Chisinau', 'Europe/Tiraspol')), + ('me-europe-podgorica', '黑山 · 波德戈里察', 'ME', 'Europe/Podgorica', 'Central Europe Standard Time', 'sr-ME', ('Europe/Podgorica',)), + ('mf-america-marigot', '法属圣马丁 · 马里戈特', 'MF', 'America/Marigot', 'SA Western Standard Time', 'fr-MF', ('America/Marigot',)), + ('mg-indian-antananarivo', '马达加斯加 · 安塔那那利佛', 'MG', 'Indian/Antananarivo', 'E. Africa Standard Time', 'mg-MG', ('Indian/Antananarivo',)), + ('mh-pacific-kwajalein', '马绍尔群岛 · 夸贾林', 'MH', 'Pacific/Kwajalein', 'UTC+12', '', ('Pacific/Kwajalein', 'Kwajalein')), + ('mh-pacific-majuro', '马绍尔群岛 · 马朱罗', 'MH', 'Pacific/Majuro', 'UTC+12', '', ('Pacific/Majuro',)), + ('mk-europe-skopje', '北马其顿 · 斯科普里', 'MK', 'Europe/Skopje', 'Central European Standard Time', 'mk-MK', ('Europe/Skopje',)), + ('ml-africa-bamako', '马里 · 巴马科', 'ML', 'Africa/Bamako', 'Greenwich Standard Time', 'bm-ML', ('Africa/Bamako', 'Africa/Timbuktu')), + ('mm-asia-rangoon', '缅甸 · 仰光', 'MM', 'Asia/Rangoon', 'Myanmar Standard Time', 'my-MM', ('Asia/Rangoon', 'Asia/Yangon')), + ('mn-asia-hovd', '蒙古 · 科布多', 'MN', 'Asia/Hovd', 'W. Mongolia Standard Time', 'mn-MN', ('Asia/Hovd',)), + ('mn-asia-ulaanbaatar', '蒙古 · 乌兰巴托', 'MN', 'Asia/Ulaanbaatar', 'Ulaanbaatar Standard Time', 'mn-MN', ('Asia/Ulaanbaatar', 'Asia/Ulan_Bator', 'Asia/Choibalsan')), + ('mo-asia-macau', '中国澳门 · 澳门', 'MO', 'Asia/Macau', 'China Standard Time', 'zh-MO', ('Asia/Macau', 'Asia/Macao')), + ('mp-pacific-saipan', '北马里亚纳群岛 · 塞班', 'MP', 'Pacific/Saipan', 'West Pacific Standard Time', '', ('Pacific/Saipan',)), + ('mq-america-martinique', '马提尼克 · 马提尼克', 'MQ', 'America/Martinique', 'SA Western Standard Time', 'fr-MQ', ('America/Martinique',)), + ('mr-africa-nouakchott', '毛里塔尼亚 · 努瓦克肖特', 'MR', 'Africa/Nouakchott', 'Greenwich Standard Time', 'ar-MR', ('Africa/Nouakchott',)), + ('ms-america-montserrat', '蒙特塞拉特 · 蒙特塞拉特', 'MS', 'America/Montserrat', 'SA Western Standard Time', '', ('America/Montserrat',)), + ('mt-europe-malta', '马耳他 · 马耳他', 'MT', 'Europe/Malta', 'W. Europe Standard Time', 'mt-MT', ('Europe/Malta',)), + ('mu-indian-mauritius', '毛里求斯 · 毛里求斯', 'MU', 'Indian/Mauritius', 'Mauritius Standard Time', 'fr-MU', ('Indian/Mauritius',)), + ('mv-indian-maldives', '马尔代夫 · 马尔代夫', 'MV', 'Indian/Maldives', 'West Asia Standard Time', 'dv-MV', ('Indian/Maldives',)), + ('mw-africa-blantyre', '马拉维 · 布兰太尔', 'MW', 'Africa/Blantyre', 'South Africa Standard Time', '', ('Africa/Blantyre',)), + ('mx-america-bahia-banderas', '墨西哥 · 巴伊亚班德拉斯', 'MX', 'America/Bahia_Banderas', 'Central Standard Time (Mexico)', 'es-MX', ('America/Bahia_Banderas',)), + ('mx-america-cancun', '墨西哥 · 坎昆', 'MX', 'America/Cancun', 'Eastern Standard Time (Mexico)', 'es-MX', ('America/Cancun',)), + ('mx-america-chihuahua', '墨西哥 · 奇瓦瓦', 'MX', 'America/Chihuahua', 'Central Standard Time (Mexico)', 'es-MX', ('America/Chihuahua',)), + ('mx-america-ciudad-juarez', '墨西哥 · 华雷斯城', 'MX', 'America/Ciudad_Juarez', 'Mountain Standard Time', 'es-MX', ('America/Ciudad_Juarez',)), + ('mx-america-hermosillo', '墨西哥 · 埃莫西约', 'MX', 'America/Hermosillo', 'US Mountain Standard Time', 'es-MX', ('America/Hermosillo',)), + ('mx-america-matamoros', '墨西哥 · 马塔莫罗斯', 'MX', 'America/Matamoros', 'Central Standard Time', 'es-MX', ('America/Matamoros',)), + ('mx-america-mazatlan', '墨西哥 · 马萨特兰', 'MX', 'America/Mazatlan', 'Mountain Standard Time (Mexico)', 'es-MX', ('America/Mazatlan', 'Mexico/BajaSur')), + ('mx-america-merida', '墨西哥 · 梅里达', 'MX', 'America/Merida', 'Central Standard Time (Mexico)', 'es-MX', ('America/Merida',)), + ('mx-america-mexico-city', '墨西哥 · 墨西哥城', 'MX', 'America/Mexico_City', 'Central Standard Time (Mexico)', 'es-MX', ('America/Mexico_City', 'Mexico/General')), + ('mx-america-monterrey', '墨西哥 · 蒙特雷', 'MX', 'America/Monterrey', 'Central Standard Time (Mexico)', 'es-MX', ('America/Monterrey',)), + ('mx-america-ojinaga', '墨西哥 · 奥希纳加', 'MX', 'America/Ojinaga', 'Central Standard Time', 'es-MX', ('America/Ojinaga',)), + ('mx-america-tijuana', '墨西哥 · 蒂华纳', 'MX', 'America/Tijuana', 'Pacific Standard Time (Mexico)', 'es-MX', ('America/Tijuana', 'America/Ensenada', 'Mexico/BajaNorte', 'America/Santa_Isabel')), + ('my-asia-kuala-lumpur', '马来西亚 · 吉隆坡', 'MY', 'Asia/Kuala_Lumpur', 'Singapore Standard Time', 'ms-MY', ('Asia/Kuala_Lumpur',)), + ('my-asia-kuching', '马来西亚 · 古晋', 'MY', 'Asia/Kuching', 'Singapore Standard Time', 'ms-MY', ('Asia/Kuching',)), + ('mz-africa-maputo', '莫桑比克 · 马普托', 'MZ', 'Africa/Maputo', 'South Africa Standard Time', 'pt-MZ', ('Africa/Maputo',)), + ('na-africa-windhoek', '纳米比亚 · 温得和克', 'NA', 'Africa/Windhoek', 'Namibia Standard Time', 'af-NA', ('Africa/Windhoek',)), + ('nc-pacific-noumea', '新喀里多尼亚 · 努美阿', 'NC', 'Pacific/Noumea', 'Central Pacific Standard Time', 'fr-NC', ('Pacific/Noumea',)), + ('ne-africa-niamey', '尼日尔 · 尼亚美', 'NE', 'Africa/Niamey', 'W. Central Africa Standard Time', 'ha-NE', ('Africa/Niamey',)), + ('nf-pacific-norfolk', '诺福克岛 · 诺福克', 'NF', 'Pacific/Norfolk', 'Norfolk Standard Time', '', ('Pacific/Norfolk',)), + ('ng-africa-lagos', '尼日利亚 · 拉各斯', 'NG', 'Africa/Lagos', 'W. Central Africa Standard Time', '', ('Africa/Lagos',)), + ('ni-america-managua', '尼加拉瓜 · 马那瓜', 'NI', 'America/Managua', 'Central America Standard Time', 'es-NI', ('America/Managua',)), + ('nl-europe-amsterdam', '荷兰 · 阿姆斯特丹', 'NL', 'Europe/Amsterdam', 'W. Europe Standard Time', 'nl-NL', ('Europe/Amsterdam',)), + ('no-europe-oslo', '挪威 · 奥斯陆', 'NO', 'Europe/Oslo', 'W. Europe Standard Time', 'nb-NO', ('Europe/Oslo',)), + ('np-asia-katmandu', '尼泊尔 · 加德满都', 'NP', 'Asia/Katmandu', 'Nepal Standard Time', 'ne-NP', ('Asia/Katmandu', 'Asia/Kathmandu')), + ('nr-pacific-nauru', '瑙鲁 · 瑙鲁', 'NR', 'Pacific/Nauru', 'UTC+12', '', ('Pacific/Nauru',)), + ('nu-pacific-niue', '纽埃 · 纽埃', 'NU', 'Pacific/Niue', 'UTC-11', '', ('Pacific/Niue',)), + ('nz-pacific-auckland', '新西兰 · 奥克兰', 'NZ', 'Pacific/Auckland', 'New Zealand Standard Time', '', ('Pacific/Auckland', 'NZ')), + ('nz-pacific-chatham', '新西兰 · 查塔姆', 'NZ', 'Pacific/Chatham', 'Chatham Islands Standard Time', '', ('Pacific/Chatham', 'NZ-CHAT')), + ('om-asia-muscat', '阿曼 · 马斯喀特', 'OM', 'Asia/Muscat', 'Arabian Standard Time', 'ar-OM', ('Asia/Muscat',)), + ('pa-america-panama', '巴拿马 · 巴拿马', 'PA', 'America/Panama', 'SA Pacific Standard Time', 'es-PA', ('America/Panama', 'EST')), + ('pe-america-lima', '秘鲁 · 利马', 'PE', 'America/Lima', 'SA Pacific Standard Time', 'es-PE', ('America/Lima',)), + ('pf-pacific-gambier', '法属波利尼西亚 · 甘比尔', 'PF', 'Pacific/Gambier', 'UTC-09', 'fr-PF', ('Pacific/Gambier',)), + ('pf-pacific-marquesas', '法属波利尼西亚 · 马克萨斯', 'PF', 'Pacific/Marquesas', 'Marquesas Standard Time', 'fr-PF', ('Pacific/Marquesas',)), + ('pf-pacific-tahiti', '法属波利尼西亚 · 塔希提', 'PF', 'Pacific/Tahiti', 'Hawaiian Standard Time', 'fr-PF', ('Pacific/Tahiti',)), + ('pg-pacific-bougainville', '巴布亚新几内亚 · 布干维尔', 'PG', 'Pacific/Bougainville', 'Bougainville Standard Time', 'tpi-PG', ('Pacific/Bougainville',)), + ('pg-pacific-port-moresby', '巴布亚新几内亚 · 莫尔兹比港', 'PG', 'Pacific/Port_Moresby', 'West Pacific Standard Time', 'tpi-PG', ('Pacific/Port_Moresby',)), + ('ph-asia-manila', '菲律宾 · 马尼拉', 'PH', 'Asia/Manila', 'Singapore Standard Time', 'fil-PH', ('Asia/Manila',)), + ('pk-asia-karachi', '巴基斯坦 · 卡拉奇', 'PK', 'Asia/Karachi', 'Pakistan Standard Time', 'ur-PK', ('Asia/Karachi',)), + ('pl-europe-warsaw', '波兰 · 华沙', 'PL', 'Europe/Warsaw', 'Central European Standard Time', 'pl-PL', ('Europe/Warsaw', 'Poland')), + ('pm-america-miquelon', '圣皮埃尔和密克隆群岛 · 密克隆', 'PM', 'America/Miquelon', 'Saint Pierre Standard Time', 'fr-PM', ('America/Miquelon',)), + ('pn-pacific-pitcairn', '皮特凯恩群岛 · 皮特凯恩', 'PN', 'Pacific/Pitcairn', 'UTC-08', '', ('Pacific/Pitcairn',)), + ('pr-america-puerto-rico', '波多黎各 · 波多黎各', 'PR', 'America/Puerto_Rico', 'SA Western Standard Time', 'es-PR', ('America/Puerto_Rico',)), + ('ps-asia-gaza', '巴勒斯坦领土 · 加沙', 'PS', 'Asia/Gaza', 'West Bank Standard Time', 'ar-PS', ('Asia/Gaza',)), + ('ps-asia-hebron', '巴勒斯坦领土 · 希伯伦', 'PS', 'Asia/Hebron', 'West Bank Standard Time', 'ar-PS', ('Asia/Hebron',)), + ('pt-atlantic-azores', '葡萄牙 · 亚速尔群岛', 'PT', 'Atlantic/Azores', 'Azores Standard Time', 'pt-PT', ('Atlantic/Azores',)), + ('pt-atlantic-madeira', '葡萄牙 · 马德拉', 'PT', 'Atlantic/Madeira', 'GMT Standard Time', 'pt-PT', ('Atlantic/Madeira',)), + ('pt-europe-lisbon', '葡萄牙 · 里斯本', 'PT', 'Europe/Lisbon', 'GMT Standard Time', 'pt-PT', ('Europe/Lisbon', 'Portugal', 'WET')), + ('pw-pacific-palau', '帕劳 · 帕劳', 'PW', 'Pacific/Palau', 'Tokyo Standard Time', 'pau-PW', ('Pacific/Palau',)), + ('py-america-asuncion', '巴拉圭 · 亚松森', 'PY', 'America/Asuncion', 'Paraguay Standard Time', 'gn-PY', ('America/Asuncion',)), + ('qa-asia-qatar', '卡塔尔 · 卡塔尔', 'QA', 'Asia/Qatar', 'Arab Standard Time', 'ar-QA', ('Asia/Qatar',)), + ('re-indian-reunion', '留尼汪 · 留尼汪', 'RE', 'Indian/Reunion', 'Mauritius Standard Time', 'fr-RE', ('Indian/Reunion',)), + ('ro-europe-bucharest', '罗马尼亚 · 布加勒斯特', 'RO', 'Europe/Bucharest', 'GTB Standard Time', 'ro-RO', ('Europe/Bucharest',)), + ('rs-europe-belgrade', '塞尔维亚 · 贝尔格莱德', 'RS', 'Europe/Belgrade', 'Central Europe Standard Time', 'sr-RS', ('Europe/Belgrade',)), + ('ru-asia-anadyr', '俄罗斯 · 阿纳德尔', 'RU', 'Asia/Anadyr', 'Russia Time Zone 11', 'ru-RU', ('Asia/Anadyr',)), + ('ru-asia-barnaul', '俄罗斯 · 巴尔瑙尔', 'RU', 'Asia/Barnaul', 'Altai Standard Time', 'ru-RU', ('Asia/Barnaul',)), + ('ru-asia-chita', '俄罗斯 · 赤塔', 'RU', 'Asia/Chita', 'Transbaikal Standard Time', 'ru-RU', ('Asia/Chita',)), + ('ru-asia-irkutsk', '俄罗斯 · 伊尔库茨克', 'RU', 'Asia/Irkutsk', 'North Asia East Standard Time', 'ru-RU', ('Asia/Irkutsk',)), + ('ru-asia-kamchatka', '俄罗斯 · 堪察加', 'RU', 'Asia/Kamchatka', 'Russia Time Zone 11', 'ru-RU', ('Asia/Kamchatka',)), + ('ru-asia-khandyga', '俄罗斯 · 汉德加', 'RU', 'Asia/Khandyga', 'Yakutsk Standard Time', 'ru-RU', ('Asia/Khandyga',)), + ('ru-asia-krasnoyarsk', '俄罗斯 · 克拉斯诺亚尔斯克', 'RU', 'Asia/Krasnoyarsk', 'North Asia Standard Time', 'ru-RU', ('Asia/Krasnoyarsk',)), + ('ru-asia-magadan', '俄罗斯 · 马加丹', 'RU', 'Asia/Magadan', 'Magadan Standard Time', 'ru-RU', ('Asia/Magadan',)), + ('ru-asia-novokuznetsk', '俄罗斯 · 新库兹涅茨克', 'RU', 'Asia/Novokuznetsk', 'North Asia Standard Time', 'ru-RU', ('Asia/Novokuznetsk',)), + ('ru-asia-novosibirsk', '俄罗斯 · 新西伯利亚', 'RU', 'Asia/Novosibirsk', 'N. Central Asia Standard Time', 'ru-RU', ('Asia/Novosibirsk',)), + ('ru-asia-omsk', '俄罗斯 · 鄂木斯克', 'RU', 'Asia/Omsk', 'Omsk Standard Time', 'ru-RU', ('Asia/Omsk',)), + ('ru-asia-sakhalin', '俄罗斯 · 萨哈林', 'RU', 'Asia/Sakhalin', 'Sakhalin Standard Time', 'ru-RU', ('Asia/Sakhalin',)), + ('ru-asia-srednekolymsk', '俄罗斯 · 中科雷姆斯克', 'RU', 'Asia/Srednekolymsk', 'Russia Time Zone 10', 'ru-RU', ('Asia/Srednekolymsk',)), + ('ru-asia-tomsk', '俄罗斯 · 托木斯克', 'RU', 'Asia/Tomsk', 'Tomsk Standard Time', 'ru-RU', ('Asia/Tomsk',)), + ('ru-asia-ust-nera', '俄罗斯 · 乌斯内拉', 'RU', 'Asia/Ust-Nera', 'Vladivostok Standard Time', 'ru-RU', ('Asia/Ust-Nera',)), + ('ru-asia-vladivostok', '俄罗斯 · 海参崴', 'RU', 'Asia/Vladivostok', 'Vladivostok Standard Time', 'ru-RU', ('Asia/Vladivostok',)), + ('ru-asia-yakutsk', '俄罗斯 · 雅库茨克', 'RU', 'Asia/Yakutsk', 'Yakutsk Standard Time', 'ru-RU', ('Asia/Yakutsk',)), + ('ru-asia-yekaterinburg', '俄罗斯 · 叶卡捷琳堡', 'RU', 'Asia/Yekaterinburg', 'Ekaterinburg Standard Time', 'ru-RU', ('Asia/Yekaterinburg',)), + ('ru-europe-astrakhan', '俄罗斯 · 阿斯特拉罕', 'RU', 'Europe/Astrakhan', 'Astrakhan Standard Time', 'ru-RU', ('Europe/Astrakhan',)), + ('ru-europe-kaliningrad', '俄罗斯 · 加里宁格勒', 'RU', 'Europe/Kaliningrad', 'Kaliningrad Standard Time', 'ru-RU', ('Europe/Kaliningrad',)), + ('ru-europe-kirov', '俄罗斯 · 基洛夫', 'RU', 'Europe/Kirov', 'Russian Standard Time', 'ru-RU', ('Europe/Kirov',)), + ('ru-europe-moscow', '俄罗斯 · 莫斯科', 'RU', 'Europe/Moscow', 'Russian Standard Time', 'ru-RU', ('Europe/Moscow', 'W-SU')), + ('ru-europe-samara', '俄罗斯 · 萨马拉', 'RU', 'Europe/Samara', 'Russia Time Zone 3', 'ru-RU', ('Europe/Samara',)), + ('ru-europe-saratov', '俄罗斯 · 萨拉托夫', 'RU', 'Europe/Saratov', 'Saratov Standard Time', 'ru-RU', ('Europe/Saratov',)), + ('ru-europe-ulyanovsk', '俄罗斯 · 乌里扬诺夫斯克', 'RU', 'Europe/Ulyanovsk', 'Astrakhan Standard Time', 'ru-RU', ('Europe/Ulyanovsk',)), + ('ru-europe-volgograd', '俄罗斯 · 伏尔加格勒', 'RU', 'Europe/Volgograd', 'Volgograd Standard Time', 'ru-RU', ('Europe/Volgograd',)), + ('rw-africa-kigali', '卢旺达 · 基加利', 'RW', 'Africa/Kigali', 'South Africa Standard Time', 'rw-RW', ('Africa/Kigali',)), + ('sa-asia-riyadh', '沙特阿拉伯 · 利雅得', 'SA', 'Asia/Riyadh', 'Arab Standard Time', 'ar-SA', ('Asia/Riyadh',)), + ('sb-pacific-guadalcanal', '所罗门群岛 · 瓜达尔卡纳尔', 'SB', 'Pacific/Guadalcanal', 'Central Pacific Standard Time', '', ('Pacific/Guadalcanal',)), + ('sc-indian-mahe', '塞舌尔 · 马埃岛', 'SC', 'Indian/Mahe', 'Mauritius Standard Time', 'fr-SC', ('Indian/Mahe',)), + ('sd-africa-khartoum', '苏丹 · 喀土穆', 'SD', 'Africa/Khartoum', 'Sudan Standard Time', 'ar-SD', ('Africa/Khartoum',)), + ('se-europe-stockholm', '瑞典 · 斯德哥尔摩', 'SE', 'Europe/Stockholm', 'W. Europe Standard Time', 'sv-SE', ('Europe/Stockholm',)), + ('sg-asia-singapore', '新加坡 · 新加坡', 'SG', 'Asia/Singapore', 'Singapore Standard Time', '', ('Asia/Singapore', 'Singapore')), + ('sh-atlantic-st-helena', '圣赫勒拿 · 圣赫勒拿', 'SH', 'Atlantic/St_Helena', 'Greenwich Standard Time', '', ('Atlantic/St_Helena',)), + ('si-europe-ljubljana', '斯洛文尼亚 · 卢布尔雅那', 'SI', 'Europe/Ljubljana', 'Central Europe Standard Time', 'sl-SI', ('Europe/Ljubljana',)), + ('sj-arctic-longyearbyen', '斯瓦尔巴和扬马延 · 朗伊尔城', 'SJ', 'Arctic/Longyearbyen', 'W. Europe Standard Time', 'nb-SJ', ('Arctic/Longyearbyen', 'Atlantic/Jan_Mayen')), + ('sk-europe-bratislava', '斯洛伐克 · 布拉迪斯拉发', 'SK', 'Europe/Bratislava', 'Central Europe Standard Time', 'sk-SK', ('Europe/Bratislava',)), + ('sl-africa-freetown', '塞拉利昂 · 弗里敦', 'SL', 'Africa/Freetown', 'Greenwich Standard Time', '', ('Africa/Freetown',)), + ('sm-europe-san-marino', '圣马力诺 · 圣马力诺', 'SM', 'Europe/San_Marino', 'W. Europe Standard Time', 'it-SM', ('Europe/San_Marino',)), + ('sn-africa-dakar', '塞内加尔 · 达喀尔', 'SN', 'Africa/Dakar', 'Greenwich Standard Time', 'wo-SN', ('Africa/Dakar',)), + ('so-africa-mogadishu', '索马里 · 摩加迪沙', 'SO', 'Africa/Mogadishu', 'E. Africa Standard Time', 'so-SO', ('Africa/Mogadishu',)), + ('sr-america-paramaribo', '苏里南 · 帕拉马里博', 'SR', 'America/Paramaribo', 'SA Eastern Standard Time', 'nl-SR', ('America/Paramaribo',)), + ('ss-africa-juba', '南苏丹 · 朱巴', 'SS', 'Africa/Juba', 'South Sudan Standard Time', 'ar-SS', ('Africa/Juba',)), + ('st-africa-sao-tome', '圣多美和普林西比 · 圣多美', 'ST', 'Africa/Sao_Tome', 'Sao Tome Standard Time', 'pt-ST', ('Africa/Sao_Tome',)), + ('sv-america-el-salvador', '萨尔瓦多 · 萨尔瓦多', 'SV', 'America/El_Salvador', 'Central America Standard Time', 'es-SV', ('America/El_Salvador',)), + ('sx-america-lower-princes', '荷属圣马丁 · 下太子区', 'SX', 'America/Lower_Princes', 'SA Western Standard Time', '', ('America/Lower_Princes',)), + ('sy-asia-damascus', '叙利亚 · 大马士革', 'SY', 'Asia/Damascus', 'Syria Standard Time', 'ar-SY', ('Asia/Damascus',)), + ('sz-africa-mbabane', '斯威士兰 · 姆巴巴纳', 'SZ', 'Africa/Mbabane', 'South Africa Standard Time', '', ('Africa/Mbabane',)), + ('tc-america-grand-turk', '特克斯和凯科斯群岛 · 大特克', 'TC', 'America/Grand_Turk', 'Turks And Caicos Standard Time', '', ('America/Grand_Turk',)), + ('td-africa-ndjamena', '乍得 · 恩贾梅纳', 'TD', 'Africa/Ndjamena', 'W. Central Africa Standard Time', 'ar-TD', ('Africa/Ndjamena',)), + ('tf-indian-kerguelen', '法属南部领地 · 凯尔盖朗', 'TF', 'Indian/Kerguelen', 'West Asia Standard Time', 'fr-TF', ('Indian/Kerguelen',)), + ('tg-africa-lome', '多哥 · 洛美', 'TG', 'Africa/Lome', 'Greenwich Standard Time', 'fr-TG', ('Africa/Lome',)), + ('th-asia-bangkok', '泰国 · 曼谷', 'TH', 'Asia/Bangkok', 'SE Asia Standard Time', 'th-TH', ('Asia/Bangkok',)), + ('tj-asia-dushanbe', '塔吉克斯坦 · 杜尚别', 'TJ', 'Asia/Dushanbe', 'West Asia Standard Time', 'tg-TJ', ('Asia/Dushanbe',)), + ('tk-pacific-fakaofo', '托克劳 · 法考福', 'TK', 'Pacific/Fakaofo', 'UTC+13', 'tkl-TK', ('Pacific/Fakaofo',)), + ('tl-asia-dili', '东帝汶 · 帝力', 'TL', 'Asia/Dili', 'Tokyo Standard Time', 'pt-TL', ('Asia/Dili',)), + ('tm-asia-ashgabat', '土库曼斯坦 · 阿什哈巴德', 'TM', 'Asia/Ashgabat', 'West Asia Standard Time', 'tk-TM', ('Asia/Ashgabat', 'Asia/Ashkhabad')), + ('tn-africa-tunis', '突尼斯 · 突尼斯', 'TN', 'Africa/Tunis', 'W. Central Africa Standard Time', 'ar-TN', ('Africa/Tunis',)), + ('to-pacific-tongatapu', '汤加 · 东加塔布', 'TO', 'Pacific/Tongatapu', 'Tonga Standard Time', 'to-TO', ('Pacific/Tongatapu',)), + ('tr-europe-istanbul', '土耳其 · 伊斯坦布尔', 'TR', 'Europe/Istanbul', 'Turkey Standard Time', 'tr-TR', ('Europe/Istanbul', 'Asia/Istanbul', 'Turkey')), + ('tt-america-port-of-spain', '特立尼达和多巴哥 · 西班牙港', 'TT', 'America/Port_of_Spain', 'SA Western Standard Time', '', ('America/Port_of_Spain',)), + ('tv-pacific-funafuti', '图瓦卢 · 富纳富提', 'TV', 'Pacific/Funafuti', 'UTC+12', 'tvl-TV', ('Pacific/Funafuti',)), + ('tw-asia-taipei', '中国台湾 · 台北', 'TW', 'Asia/Taipei', 'Taipei Standard Time', 'zh-TW', ('Asia/Taipei', 'ROC')), + ('tz-africa-dar-es-salaam', '坦桑尼亚 · 达累斯萨拉姆', 'TZ', 'Africa/Dar_es_Salaam', 'E. Africa Standard Time', 'sw-TZ', ('Africa/Dar_es_Salaam',)), + ('ua-europe-kiev', '乌克兰 · 基辅', 'UA', 'Europe/Kiev', 'FLE Standard Time', 'uk-UA', ('Europe/Kiev', 'Europe/Kyiv', 'Europe/Zaporozhye', 'Europe/Uzhgorod')), + ('ua-europe-simferopol', '乌克兰 · 辛菲罗波尔', 'UA', 'Europe/Simferopol', 'Russian Standard Time', 'uk-UA', ('Europe/Simferopol',)), + ('ug-africa-kampala', '乌干达 · 坎帕拉', 'UG', 'Africa/Kampala', 'E. Africa Standard Time', 'sw-UG', ('Africa/Kampala',)), + ('um-pacific-midway', '美国本土外小岛屿 · 中途岛', 'UM', 'Pacific/Midway', 'UTC-11', '', ('Pacific/Midway',)), + ('um-pacific-wake', '美国本土外小岛屿 · 威克', 'UM', 'Pacific/Wake', 'UTC+12', '', ('Pacific/Wake',)), + ('us-america-adak', '美国 · 埃达克', 'US', 'America/Adak', 'Aleutian Standard Time', '', ('America/Adak', 'America/Atka', 'US/Aleutian')), + ('us-america-anchorage', '美国 · 安克雷奇', 'US', 'America/Anchorage', 'Alaskan Standard Time', '', ('America/Anchorage', 'US/Alaska')), + ('us-america-boise', '美国 · 博伊西', 'US', 'America/Boise', 'Mountain Standard Time', '', ('America/Boise',)), + ('us-america-chicago', '美国 · 芝加哥', 'US', 'America/Chicago', 'Central Standard Time', '', ('America/Chicago', 'US/Central', 'CST6CDT')), + ('us-america-denver', '美国 · 丹佛', 'US', 'America/Denver', 'Mountain Standard Time', '', ('America/Denver', 'America/Shiprock', 'Navajo', 'US/Mountain', 'MST7MDT')), + ('us-america-detroit', '美国 · 底特律', 'US', 'America/Detroit', 'Eastern Standard Time', '', ('America/Detroit', 'US/Michigan')), + ('us-america-indiana-knox', '美国 · 印第安纳州诺克斯', 'US', 'America/Indiana/Knox', 'Central Standard Time', '', ('America/Indiana/Knox', 'America/Knox_IN', 'US/Indiana-Starke')), + ('us-america-indiana-marengo', '美国 · 印第安纳州马伦戈', 'US', 'America/Indiana/Marengo', 'US Eastern Standard Time', '', ('America/Indiana/Marengo',)), + ('us-america-indiana-petersburg', '美国 · 印第安纳州彼得斯堡', 'US', 'America/Indiana/Petersburg', 'Eastern Standard Time', '', ('America/Indiana/Petersburg',)), + ('us-america-indiana-tell-city', '美国 · 印第安纳州特尔城', 'US', 'America/Indiana/Tell_City', 'Central Standard Time', '', ('America/Indiana/Tell_City',)), + ('us-america-indiana-vevay', '美国 · 印第安纳州维维市', 'US', 'America/Indiana/Vevay', 'US Eastern Standard Time', '', ('America/Indiana/Vevay',)), + ('us-america-indiana-vincennes', '美国 · 印第安纳州温森斯', 'US', 'America/Indiana/Vincennes', 'Eastern Standard Time', '', ('America/Indiana/Vincennes',)), + ('us-america-indiana-winamac', '美国 · 印第安纳州威纳马克', 'US', 'America/Indiana/Winamac', 'Eastern Standard Time', '', ('America/Indiana/Winamac',)), + ('us-america-indianapolis', '美国 · 印第安纳波利斯', 'US', 'America/Indianapolis', 'US Eastern Standard Time', '', ('America/Indianapolis', 'America/Fort_Wayne', 'America/Indiana/Indianapolis', 'US/East-Indiana')), + ('us-america-juneau', '美国 · 朱诺', 'US', 'America/Juneau', 'Alaskan Standard Time', '', ('America/Juneau',)), + ('us-america-kentucky-monticello', '美国 · 肯塔基州蒙蒂塞洛', 'US', 'America/Kentucky/Monticello', 'Eastern Standard Time', '', ('America/Kentucky/Monticello',)), + ('us-america-los-angeles', '美国 · 洛杉矶', 'US', 'America/Los_Angeles', 'Pacific Standard Time', '', ('America/Los_Angeles', 'US/Pacific', 'US/Pacific-New', 'PST8PDT')), + ('us-america-louisville', '美国 · 路易斯维尔', 'US', 'America/Louisville', 'Eastern Standard Time', '', ('America/Louisville', 'America/Kentucky/Louisville')), + ('us-america-menominee', '美国 · 梅诺米尼', 'US', 'America/Menominee', 'Central Standard Time', '', ('America/Menominee',)), + ('us-america-metlakatla', '美国 · 梅特拉卡特拉', 'US', 'America/Metlakatla', 'Alaskan Standard Time', '', ('America/Metlakatla',)), + ('us-america-new-york', '美国 · 纽约', 'US', 'America/New_York', 'Eastern Standard Time', '', ('America/New_York', 'US/Eastern', 'EST5EDT')), + ('us-america-nome', '美国 · 诺姆', 'US', 'America/Nome', 'Alaskan Standard Time', '', ('America/Nome',)), + ('us-america-north-dakota-beulah', '美国 · 北达科他州比尤拉', 'US', 'America/North_Dakota/Beulah', 'Central Standard Time', '', ('America/North_Dakota/Beulah',)), + ('us-america-north-dakota-center', '美国 · 北达科他州申特', 'US', 'America/North_Dakota/Center', 'Central Standard Time', '', ('America/North_Dakota/Center',)), + ('us-america-north-dakota-new-salem', '美国 · 北达科他州新塞勒姆', 'US', 'America/North_Dakota/New_Salem', 'Central Standard Time', '', ('America/North_Dakota/New_Salem',)), + ('us-america-phoenix', '美国 · 凤凰城', 'US', 'America/Phoenix', 'US Mountain Standard Time', '', ('America/Phoenix', 'US/Arizona', 'MST')), + ('us-america-sitka', '美国 · 锡特卡', 'US', 'America/Sitka', 'Alaskan Standard Time', '', ('America/Sitka',)), + ('us-america-yakutat', '美国 · 亚库塔特', 'US', 'America/Yakutat', 'Alaskan Standard Time', '', ('America/Yakutat',)), + ('us-pacific-honolulu', '美国 · 檀香山', 'US', 'Pacific/Honolulu', 'Hawaiian Standard Time', '', ('Pacific/Honolulu', 'US/Hawaii', 'Pacific/Johnston', 'HST')), + ('uy-america-montevideo', '乌拉圭 · 蒙得维的亚', 'UY', 'America/Montevideo', 'Montevideo Standard Time', 'es-UY', ('America/Montevideo',)), + ('uz-asia-samarkand', '乌兹别克斯坦 · 撒马尔罕', 'UZ', 'Asia/Samarkand', 'West Asia Standard Time', 'uz-UZ', ('Asia/Samarkand',)), + ('uz-asia-tashkent', '乌兹别克斯坦 · 塔什干', 'UZ', 'Asia/Tashkent', 'West Asia Standard Time', 'uz-UZ', ('Asia/Tashkent',)), + ('va-europe-vatican', '梵蒂冈 · 梵蒂冈', 'VA', 'Europe/Vatican', 'W. Europe Standard Time', 'it-VA', ('Europe/Vatican',)), + ('vc-america-st-vincent', '圣文森特和格林纳丁斯 · 圣文森特', 'VC', 'America/St_Vincent', 'SA Western Standard Time', '', ('America/St_Vincent',)), + ('ve-america-caracas', '委内瑞拉 · 加拉加斯', 'VE', 'America/Caracas', 'Venezuela Standard Time', 'es-VE', ('America/Caracas',)), + ('vg-america-tortola', '英属维尔京群岛 · 托尔托拉', 'VG', 'America/Tortola', 'SA Western Standard Time', '', ('America/Tortola',)), + ('vi-america-st-thomas', '美属维尔京群岛 · 圣托马斯', 'VI', 'America/St_Thomas', 'SA Western Standard Time', '', ('America/St_Thomas', 'America/Virgin')), + ('vn-asia-saigon', '越南 · 胡志明市', 'VN', 'Asia/Saigon', 'SE Asia Standard Time', 'vi-VN', ('Asia/Saigon', 'Asia/Ho_Chi_Minh')), + ('vu-pacific-efate', '瓦努阿图 · 埃法特', 'VU', 'Pacific/Efate', 'Central Pacific Standard Time', 'bi-VU', ('Pacific/Efate',)), + ('wf-pacific-wallis', '瓦利斯和富图纳 · 瓦利斯', 'WF', 'Pacific/Wallis', 'UTC+12', 'fr-WF', ('Pacific/Wallis',)), + ('ws-pacific-apia', '萨摩亚 · 阿皮亚', 'WS', 'Pacific/Apia', 'Samoa Standard Time', 'sm-WS', ('Pacific/Apia',)), + ('ye-asia-aden', '也门 · 亚丁', 'YE', 'Asia/Aden', 'Arab Standard Time', 'ar-YE', ('Asia/Aden',)), + ('yt-indian-mayotte', '马约特 · 马约特', 'YT', 'Indian/Mayotte', 'E. Africa Standard Time', 'fr-YT', ('Indian/Mayotte',)), + ('za-africa-johannesburg', '南非 · 约翰内斯堡', 'ZA', 'Africa/Johannesburg', 'South Africa Standard Time', '', ('Africa/Johannesburg',)), + ('zm-africa-lusaka', '赞比亚 · 卢萨卡', 'ZM', 'Africa/Lusaka', 'South Africa Standard Time', '', ('Africa/Lusaka',)), + ('zw-africa-harare', '津巴布韦 · 哈拉雷', 'ZW', 'Africa/Harare', 'South Africa Standard Time', 'sn-ZW', ('Africa/Harare',)), +) diff --git a/envscope/region_profiles.py b/envscope/region_profiles.py index 072adac..a552115 100644 --- a/envscope/region_profiles.py +++ b/envscope/region_profiles.py @@ -1,4 +1,5 @@ """Explicit city presets; these are preferences, never proof of location.""" +from .region_data import COUNTRIES, REGIONS PROFILES = ( ('us-los-angeles', '美国 · 洛杉矶', 'US', 'America/Los_Angeles', 'Pacific Standard Time', 'en-US'), @@ -21,10 +22,20 @@ ('au-perth', '澳大利亚 · 珀斯', 'AU', 'Australia/Perth', 'W. Australia Standard Time', 'en-AU'), ) +# Retain previously saved preset IDs. Additional cities/timezones use CLDR mappings. +_existing = {(row[2], row[3]) for row in PROFILES} +PROFILES += tuple(row[:6] for row in REGIONS if (row[2], row[3]) not in _existing) +_aliases = {(row[2], row[3]): row[6] for row in REGIONS} + + +def countries(): + return [dict(code=code, label=label) for code, label in COUNTRIES] + def profiles(system): return [dict(id=key, label=label, country=country, timezone=windows if system == 'Windows' else iana, - locale=locale if system == 'Windows' else locale.replace('-', '_'), language=locale, iana=iana) + locale=locale if system == 'Windows' else locale.replace('-', '_'), language=locale, iana=iana, + aliases=list(_aliases.get((country, iana), (iana,)))) for key, label, country, iana, windows, locale in PROFILES] diff --git a/envscope/remedies.py b/envscope/remedies.py index 73134e7..222eed8 100644 --- a/envscope/remedies.py +++ b/envscope/remedies.py @@ -471,8 +471,10 @@ def prepare(self, report, check_id=None, enhance_cli=False, proxy=None, check_id if check_ids is not None: from .inspection import validate_selection, problem_prefixes checks = validate_selection(check_ids) - if check_id is not None or enhance_cli or proxy is not None: + if check_id is not None: raise ValueError('勾选处理不能混用其他修改范围') + if (enhance_cli or proxy is not None) and 'extra.cli_env' not in checks: + raise ValueError('完善 Code 配置必须包含已检测的 Code 连接配置项目') prefixes = problem_prefixes(checks) problems = [p for p in problems if p['id'].startswith(prefixes)] if check_id is not None: diff --git a/envscope/service.py b/envscope/service.py index 6bcc785..dd6e0b0 100644 --- a/envscope/service.py +++ b/envscope/service.py @@ -189,7 +189,7 @@ def preflight(): report=self.server.last_raw_report, preflight=preflight) if request['action'] == 'check-config-save' and result.get('requires_retest'): self.server.last_raw_report = None - if request["action"] == "solution-apply" and result.get("before"): + if request["action"] in ("solution-apply", "inspection-apply") and result.get("before"): self.server.solution_before = result.pop("before") elif request["action"] in ("solution-open", "solution-undo") and result.get("state") in ("opened", "completed") and self.server.last_raw_report: self.server.solution_before = {"problems": [{"id": p["id"], "title": p["title"]} for p in self.server.last_raw_report["diagnosis"]["problems"]]} diff --git a/envscope/target_environment.py b/envscope/target_environment.py index 80e2084..877eb7a 100644 --- a/envscope/target_environment.py +++ b/envscope/target_environment.py @@ -89,9 +89,10 @@ def suggest(settings, inventory, cli, proxies): tun = any(re.search(r'TUN|TAP|utun|WireGuard|VPN', str(a.get('InterfaceDescription', a.get('name', ''))), re.I) for a in adapters) proposed['connection_mode'] = 'http_proxy' if proxies else 'tun' if tun else 'direct' # Observed values are shown above the form, never silently made into goals. - from .region_profiles import profiles + from .region_profiles import profiles, countries return {'state': 'completed', 'observed': actual, 'proposed': proposed, 'profiles': profiles(actual['system']), + 'countries': countries(), 'proxies': [{'url': p['url']} for p in proxies if isinstance(p.get('url'), str)], 'wireguard': network.get('wireguard', {'platform': actual['system'], 'complete': False, 'services': [], 'error': 'unavailable'}), 'note': '这是只读建议。确认仅保存检测目标,不会修改系统时区、语言、网络或账号。'} diff --git a/envscope/web/app.mjs b/envscope/web/app.mjs index b190ca0..96b7ea1 100644 --- a/envscope/web/app.mjs +++ b/envscope/web/app.mjs @@ -20,7 +20,7 @@ const sessionPromise=(async()=>{ const value=await response.json(); if(value.mode!=="desktop"||typeof value.token!=="string")return null; byId('app-version').textContent=value.release||value.version||'版本未读取'; - byId("mode-note").textContent="检测连接,合并配置与修复方案;确认后自动处理并复检。"; + byId("mode-note").textContent="确认地区与时区,检测问题,修复后持续守护。"; nodes.quit.hidden=false; return value; } catch {return null;} finally {clearTimeout(timer);} @@ -112,6 +112,7 @@ async function nativeAudit(session,snapshot,checks=null) { async function runAudit({oneStop=false,phase='check',checks=null}={}){ if(!await targetUI.ensure())return; + byId('audit-results').hidden=false; setFlowStage(phase==='recheck'?'repair':'check',phase==='recheck'?'正在重新检测配置与连接,写入成功不代替复检结果。':'正在检查连接和环境设置,尚未写入任何配置。'); document.dispatchEvent(new CustomEvent('envscope-audit-start')); byId('connection-options').open=false; @@ -156,7 +157,7 @@ async function runAudit({oneStop=false,phase='check',checks=null}={}){ }else{byId("verdict-note").textContent="当前仅检测浏览器;Windows / macOS 请使用本地启动程序完成系统检测。";} nodes.share.disabled=false; } catch {byId("verdict").textContent="检测没有完成";byId("verdict-note").textContent="本地服务或浏览器探测没有返回有效结果。请重新检测;仍失败时重新打开本地程序。缺失观测不会判定为通过。";byId("session-state").textContent="需要重新检测";} - finally {nodes.start.disabled=false;for(const id of ['usage','enhance-cli','workflow-proxy','workflow-proxy-url'])byId(id).disabled=diagnosisUI.isBusy();nodes.start.textContent="重新检测与修复";byId("audit-results").setAttribute("aria-busy","false");document.dispatchEvent(new CustomEvent('envscope-audit-end',{detail:{has_report:!!lastReport}}));if(!lastReport)setFlowStage(phase==='recheck'?'repair':'check','本次检测未完成,没有据此写入配置。请重新检测。');} + finally {nodes.start.disabled=false;for(const id of ['usage','enhance-cli','workflow-proxy','workflow-proxy-url'])byId(id).disabled=diagnosisUI.isBusy();nodes.start.textContent="重新检测";byId("audit-results").setAttribute("aria-busy","false");document.dispatchEvent(new CustomEvent('envscope-audit-end',{detail:{has_report:!!lastReport}}));if(!lastReport)setFlowStage(phase==='recheck'?'repair':'check','本次检测未完成,没有据此写入配置。请重新检测。');} if(oneStop&&lastReport)await diagnosisUI.prepareWorkflow(); } nodes.start.addEventListener("click",()=>{if(!diagnosisUI.isBusy())void runAudit({oneStop:true});}); diff --git a/envscope/web/controls.mjs b/envscope/web/controls.mjs index 54405b5..2d5fc11 100644 --- a/envscope/web/controls.mjs +++ b/envscope/web/controls.mjs @@ -30,7 +30,12 @@ export async function initDesktopControls({sessionPromise,onReport,download,rete for(const n of document.querySelectorAll(".page"))n.hidden=n.id!=="page-"+name; for(const n of document.querySelectorAll(".tab")){if(n.dataset.page===name)n.setAttribute("aria-current","page");else n.removeAttribute("aria-current");} window.scrollTo(0,0); + $('more-menu').open=false; + $('more-menu').querySelector('summary').textContent=name==='audit'?'更多功能':document.querySelector(`.tab[data-page="${name}"]`)?.textContent||'更多功能'; + const heading=$('page-'+name)?.querySelector('h1');if(heading){heading.tabIndex=-1;heading.focus({preventScroll:true});} } + document.addEventListener('click',event=>{if(!$('more-menu').contains(event.target))$('more-menu').open=false;}); + document.addEventListener('keydown',event=>{if(event.key==='Escape'&&$('more-menu').open){$('more-menu').open=false;$('more-menu').querySelector('summary').focus();}}); for(const n of document.querySelectorAll(".tab"))n.addEventListener("click",()=>page(n.dataset.page)); async function refresh(){ const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),15000); diff --git a/envscope/web/diagnosis.mjs b/envscope/web/diagnosis.mjs index a544d92..e054821 100644 --- a/envscope/web/diagnosis.mjs +++ b/envscope/web/diagnosis.mjs @@ -1,6 +1,7 @@ // Human-facing diagnosis and a reviewed plan -> execute -> recheck workflow. // Only fixed API actions and server-issued plan/backup IDs reach the backend. import {watchdogSelection} from './watchdog_ui.mjs'; +import {workflowIssues,workflowChecks} from './workflow.mjs'; const $=id=>document.getElementById(id); const el=(tag,text,cls)=>{const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n;}; const labels={"config-desktop":"备份并打开桌面配置","config-cli":"备份并打开 Code 配置","clock-settings":"打开日期与时间设置","storage-settings":"打开存储设置","proxy-settings":"打开系统代理设置","hosts-file":"打开 hosts 文件"}; @@ -10,6 +11,7 @@ const errorText={audit_running:"已有检测或操作正在进行,请等待完 export function setFlowStage(stage,text){ for(const row of document.querySelectorAll('[data-flow-step]'))row.dataset.active=String(row.dataset.flowStep===stage); $('flow-state').textContent=text; + $('flow-state').hidden=!['check','repair'].includes(stage); } export function describeProxy(item){ @@ -95,9 +97,10 @@ export function initDiagnosis({sessionPromise,retest}) { if(options!==null&&options!==optionsKey()){output.textContent='环境选项已经变化,请重新检测并审核新的方案。';$('solution-plan').replaceChildren();return;} setFlowStage('repair','正在执行已确认的改动;先备份配置,完成后自动复检。'); $('solution-plan').replaceChildren();output.textContent='正在执行已确认的方案。配置写入完成后还需复检。'; - const result=await run('solution-apply',{plan_id:value.plan_id},true); + const result=await run(value.engine==='inspection'?'inspection-apply':'solution-apply',{plan_id:value.plan_id},true); executionFailed=['partial','failed','blocked'].includes(result.state); 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.state==='waiting_install'){ $('solution-plan').append(button('安装完成,继续复检',()=>action(async()=>{await retest();}))); }else if(result.needs_retest){ @@ -114,21 +117,30 @@ export function initDiagnosis({sessionPromise,retest}) { setFlowStage('review','正在合并配置与修复方案,测试候选连接;尚未写入。'); output.textContent='正在核对可自动处理的配置和代理,生成具体方案…'; const params={report_id:report?.report_id};if(checkId)params.check_id=checkId; - if(workflow){params.enhance_cli=$('enhance-cli').checked;const selected=$('workflow-proxy').value;if(params.enhance_cli&&selected!=='auto')params.proxy=selected==='manual'?$('workflow-proxy-url').value.trim():selected;} + const integrated=workflow&&Array.isArray(report.selection?.selected); + const checks=integrated?workflowChecks(report,$('enhance-cli').checked):[]; + if(integrated&&!checks.length){ + $('solution-plan').replaceChildren();output.textContent='本轮没有需要自动修改的配置。可在下方预览持久化守护方案;未取得的必需证据仍需补充。'; + $('session-state').textContent='检测完成';setFlowStage('keep','检测完成,按需设置持久化守护。');return; + } + 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('solution-plan',params);}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;} $('solution-plan').replaceChildren(); if(workflow)$('session-state').textContent=value.state==='planned'?'方案已就绪,等待你确认':'本轮方案核对已结束'; output.textContent=[value.reason,...value.manual||[]].filter(Boolean).join('\n'); for(const item of value.connections||[]){const detail=el('details',undefined,'connection-evidence');detail.append(el('summary',item.proxy+':'+(item.verified?'API 连接已验证,未认证账号':'本次未验证可用')));for(const response of item.evidence||[])detail.append(el('p',describeProxy(response)));$('solution-plan').append(detail);} - if(value.manual_items?.length){const manual=el('details',undefined,'manual-items');manual.append(el('summary',`${value.manual_items.length} 项需要你配合处理`));for(const item of value.manual_items)manual.append(button(item.title,()=>{const row=[...document.querySelectorAll('[data-problem]')].find(n=>n.dataset.problem===item.id);if(row){const parent=row.closest('details');if(parent)parent.open=true;row.scrollIntoView({block:'start'});}}));$('solution-plan').append(manual);} + if(value.manual_items?.length){const manual=el('details',undefined,'manual-items');manual.append(el('summary',`${value.manual_items.length} 项需手动处理 · 查看步骤`));for(const item of value.manual_items){const row=el('section',undefined,'manual-guidance');row.append(el('strong',item.title));const list=el('ol');for(const step of item.steps||[])list.append(el('li',step));row.append(list);if(!item.steps?.length)row.append(button('查看对应问题',()=>{const issue=[...document.querySelectorAll('[data-problem]')].find(n=>n.dataset.problem===item.id);if(issue){issue.open=true;issue.scrollIntoView({block:'start'});}}));manual.append(row);}$('solution-plan').append(manual);} if(value.state==='planned'){ - const h=el('h3',workflow?'确认后完成这些设置和修复':'本次处理方案'),list=el('ul');for(const effect of value.effects)list.append(el('li',effect)); + const h=el('h3','将进行以下改动'),list=el('ul');for(const effect of value.effects)list.append(el('li',effect)); const key=workflow?optionsKey():null; - const apply=button(workflow?'确认配置并修复':'确认执行此方案',()=>action(()=>applyPlan(value,key)));apply.id='apply-solution'; - $('solution-plan').append(h,list,el('p','确认后才写入配置或调用维护核心,完成后自动复检。用户配置可被项目或组织设置覆盖,实际会话仍需重开并确认。','note'),apply); + const apply=button(workflow?'确认修复并复检':'确认执行此方案',()=>action(()=>applyPlan(value,key)));apply.id='apply-solution'; + $('solution-plan').append(h,list,el('p','先保留恢复记录。确认后可能请求系统权限,完成后自动复检。','note'),apply); + if(value.reason){const notes=el('details',undefined,'plan-notes');notes.append(el('summary','执行条件与恢复说明'),el('p',value.reason));$('solution-plan').append(notes);} + output.textContent=''; setFlowStage('review','方案已生成。核对下面的具体改动,确认一次后执行并复检。'); - if(workflow&&!report.assessment){$('verdict').textContent='检测完成,请核对处理方案';$('verdict-note').textContent=`本次有 ${report.diagnosis.problems.length} 项需要处理或核对的问题,方案包含 ${value.steps.length} 个执行步骤。确认后处理,完成后重新检查。`;} + if(workflow&&!report.assessment){$('verdict').textContent='检测完成,请核对处理方案';$('verdict-note').textContent=`本次有 ${report.diagnosis.problems.length} 项需要处理或核对的问题。确认后处理,完成后重新检查。`;} if(!workflow)$('solution-plan').scrollIntoView({block:'center',behavior:'auto'}); }else{ setFlowStage('review',value.state==='retest_required'?'检测已过期,请重新一键检测。':'检测和方案核对已完成;本次没有执行配置写入。'); @@ -137,7 +149,7 @@ export function initDiagnosis({sessionPromise,retest}) { 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} 分钟复检`:'',value.reason].filter(Boolean).join('。'); + $('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('。'); $('flow-upkeep-stop').hidden=!value.enabled&&!value.scheduler?.present; } $('flow-upkeep-prepare').addEventListener('click',()=>action(async()=>{ @@ -167,8 +179,12 @@ export function initDiagnosis({sessionPromise,retest}) { if($('solution-plan').children.length){$('solution-plan').replaceChildren();output.textContent='环境选项已变化,请重新检测并生成方案。';} }); function renderProblem(problem){ - const row=el('section',undefined,'problem');row.dataset.problem=problem.id; - row.append(el('h3',problem.title),el('p','发生了什么','explain-label'),el('p',problem.observed,'observed'),el('p','为什么需要处理','explain-label'),el('p',problem.cause,'cause'),el('p','接下来这样做','explain-label')); + const row=el('details',undefined,'problem');row.dataset.problem=problem.id;row.dataset.manual=String(!problem.automatic); + const summary=el('summary'),title=el('span',undefined,'problem-title'); + title.append(el('strong',problem.title),el('span',problem.observed,'observed')); + const label=problem.state==='permission'?'需要授权':problem.automatic?'可预览修复':['probe_incomplete','unavailable','review'].includes(problem.state)?'需补充确认':'需手动处理'; + summary.append(title,el('span',label,'problem-badge'));row.append(summary); + row.append(el('p',problem.cause||'按本次观测处理,修复后重新检测。','cause'),el('h3','解决办法')); const steps=el('ol');for(const step of problem.steps)steps.append(el('li',step));row.append(steps); const controls=el('div',undefined,'problem-actions'); if(labels[problem.action]){ @@ -177,10 +193,13 @@ export function initDiagnosis({sessionPromise,retest}) { const value=await run('solution-open',{problem_id:problem.id,report_id:report.report_id},problem.action.startsWith('config-'));output.textContent=value.reason; }))); }else if(problem.action==='proxy-test')controls.append(button('测试已发现代理',()=>action(proxyTest))); - else if(problem.action==='maintenance')controls.append(button('生成修复方案',()=>action(prepare))); + else if(problem.action==='maintenance')controls.append(button('生成修复方案',()=>action(()=>prepare(null,true)))); + else if(problem.action==='permission-help'){const help=button('处理读取权限',()=>document.dispatchEvent(new CustomEvent('envscope-permission',{detail:{id:problem.check,output:message,button:help}}))),message=el('p','');controls.append(help);row.append(message);} 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(['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; } function renderChecklist(value){ @@ -209,9 +228,11 @@ export function initDiagnosis({sessionPromise,retest}) { if(!diagnosis){$('verdict').textContent='历史结果需要重新分析';$('verdict-note').textContent='这份旧报告没有问题归因,请重新检测以生成处理方案。';return;} $('workspace').dataset.state='complete';$('verdict').textContent=report.guide?.headline||diagnosis.headline;$('verdict-note').textContent=report.guide?.summary||diagnosis.summary; $('coverage-count').textContent=diagnosis.problems.length?`${diagnosis.problems.length} 个问题`:'本次诊断'; - const problemNodes=diagnosis.problems.slice(0,1).map(renderProblem); - if(diagnosis.problems.length>1){const rest=el('details',undefined,'other-problems');rest.append(el('summary',`其他 ${diagnosis.problems.length-1} 项需要处理的问题`),...diagnosis.problems.slice(1).map(renderProblem));problemNodes.push(rest);} - $('problems').replaceChildren(...problemNodes); + const issues=workflowIssues(report); + $('issue-section').hidden=!issues.length; + $('issue-summary').textContent=`共 ${issues.length} 项需要处理或补充确认。每项下方列出本次结果,展开查看原因与解决办法。`; + $('problems').replaceChildren(...issues.map(renderProblem)); + $('audit-results').hidden=false; const normal=el('details');normal.append(el('summary','已取得的连接回应'),...diagnosis.normal.map(text=>el('p',text))); $('normal-observations').replaceChildren(...(diagnosis.normal.length?[normal]:[])); $('coverage-note').textContent=diagnosis.coverage; diff --git a/envscope/web/guide.mjs b/envscope/web/guide.mjs index 229b11c..ac11afc 100644 --- a/envscope/web/guide.mjs +++ b/envscope/web/guide.mjs @@ -27,6 +27,7 @@ export function initGuide({sessionPromise,run,retest,isBusy,operate,onReport}){ if(previousFocus?.isConnected)previousFocus.focus();else $('start').focus(); } function renderLesson(){ + $('learning-panel').classList.remove('learning-inline');$('learning-next').hidden=false;$('learning-skip').textContent='稍后学习'; $('learning-panel').hidden=false;$('audit-content').hidden=true;$('welcome-loading').hidden=true; $('learning-progress').textContent=`使用教学 · ${step+1} / 3`;$('learning-back').hidden=step===0; $('learning-next').textContent=step===2?'开始第一次检测':'继续';$('learning-output').textContent=''; @@ -41,7 +42,7 @@ export function initGuide({sessionPromise,run,retest,isBusy,operate,onReport}){ }else if(step===1){ $('learning-title').textContent='检测后,只用先看下一步';body.append(el('p','不用先弄懂 DNS、TUN 或每个技术名词。先跟着结果处理真正影响使用的事情。','subtle')); const rows=el('ol',undefined,'lesson-steps'); - for(const [title,text] of [['看原因','首页先说明哪里影响使用。左侧“全部检查”可查看 32 项用途和结果。没有测到的内容会写“还不能确认”,不会算成电脑故障。'],['看改动,再修复','先预览要修改的内容;你确认后才执行。修改配置先备份,完成后自动复检,可在工具页撤销。'],['回到 Claude 试用','本工具测试网络,不验证账号权限。最后需要在自己的 Claude 登录,并收到一条实际回复。只有管理多个桌面账号时,才需要在账号页保存资料。']]){const li=el('li');li.append(el('h2',title),el('p',text));rows.append(li);}body.append(rows); + for(const [title,text] of [['看原因','首页列出问题、原因和处理办法。“更多功能 → 全部检查”可查看每项用途与证据,也能全选或按需检查。未取得的必需证据会单独提示,补齐后再判断环境是否合格。'],['看改动,再修复','先预览要修改的内容;你确认后才执行。修改配置先备份,完成后自动复检,可在工具页撤销。'],['回到 Claude 试用','本工具测试网络,不验证账号权限。最后需要在自己的 Claude 登录,并收到一条实际回复。只有管理多个桌面账号时,才需要在账号页保存资料。']]){const li=el('li');li.append(el('h2',title),el('p',text));rows.append(li);}body.append(rows); }else{ $('learning-title').textContent='能正常使用后,再选择怎样保持';body.append(el('p','第一次检测只读,不会安装后台任务。下面两项都需要后续单独确认。','subtle')); const rows=el('dl',undefined,'lesson-options');for(const [title,text] of [['定时复检','按 5 / 15 / 60 分钟记录连接和配置变化,关闭界面后也可运行。'],['配置保持','可选恢复已经验证的 Code 代理与证书校验设置;每次先备份,保留其他配置。'],['开源进程保护','沿用 claude-switcher 的独立维护入口;可能停止客户端,安装前列出影响。']]){const row=el('div');row.append(el('dt',title),el('dd',text));rows.append(row);}body.append(rows,el('p','开始前,打开你平时使用的连接工具。检测会联系 Claude、网络出口和实时连接探测服务;仅在本机保留脱敏记录。','note')); @@ -52,6 +53,12 @@ export function initGuide({sessionPromise,run,retest,isBusy,operate,onReport}){ if(isBusy()||$('start').disabled)return; previousFocus=document.activeElement;document.querySelector('.tab[data-page="audit"]')?.click();step=0;usage=$('usage').value;renderLesson();window.scrollTo(0,0); } + function showInlineHelp(){ + $('audit-content').hidden=false;$('learning-panel').hidden=false;$('learning-panel').classList.add('learning-inline'); + $('learning-progress').textContent='初次使用';$('learning-title').textContent='先确定地区,再检测与修复'; + $('learning-body').replaceChildren(el('p','确认地区与时区后点击检测;查看问题和具体改动,确认修复后自动复检;最后选择需要持续守护的设置。')); + $('learning-next').hidden=true;$('learning-back').hidden=true;$('learning-skip').textContent='知道了'; + } async function finish(start){ if(saving)return;saving=true;$('learning-next').disabled=true;$('learning-skip').disabled=true;$('learning-back').disabled=true; try{ @@ -70,7 +77,7 @@ export function initGuide({sessionPromise,run,retest,isBusy,operate,onReport}){ document.addEventListener('envscope-catalog',event=>{ if(initialized)return;initialized=true;usage=event.detail.settings?.usage||'auto';$('welcome-loading').hidden=true; if(event.detail.learning?.completed){$('audit-content').hidden=false;} - else renderLesson(); + else showInlineHelp(); }); void sessionPromise.then(value=>{if(!value){initialized=true;$('welcome-loading').hidden=true;$('audit-content').hidden=false;}}); document.addEventListener('envscope-catalog-error',()=>{if(!initialized){initialized=true;$('welcome-loading').hidden=true;renderLesson();}}); @@ -86,7 +93,7 @@ export function initGuide({sessionPromise,run,retest,isBusy,operate,onReport}){ $('try-success').textContent='本次试用已由你确认';$('try-success').disabled=true; }); $('try-help').addEventListener('click',()=>{$('symptom-help').open=true;$('symptom').focus();$('symptom-help').scrollIntoView({block:'start'});}); - $('try-upkeep').addEventListener('click',()=>document.querySelector('.tab[data-page="upkeep"]')?.click()); + $('try-upkeep').addEventListener('click',()=>{$('flow-upkeep').scrollIntoView({block:'start'});$('flow-upkeep-prepare').focus();}); async function desktopAction(restart){ if(isBusy()||$('start').disabled)return; const effects=restart?'请先保存 Claude 中的工作。将先重新检测启动条件,通过后请求 Claude 正常退出,再次复检并重开;未确认退出时不会强制终止。':'将重新检测本机启动条件,通过后提交 Claude 桌面打开请求。'; diff --git a/envscope/web/index.html b/envscope/web/index.html index 09af51a..97df6fe 100644 --- a/envscope/web/index.html +++ b/envscope/web/index.html @@ -10,10 +10,15 @@ -
EnvScope正在读取版本
- +
+
EnvScope正在读取版本
+ +

正在连接本地服务…

@@ -29,52 +34,56 @@

你准备怎样使用 Claude?

@@ -121,7 +134,7 @@

接下来,到自己的 Claude 试一条消息

全部检查

勾选要检查的项目,检测后预览并一键修改。红色项目需要手动处理,展开即可看到步骤。

尚未检测,下面是完整检查目录。

-
节点地区与恢复记录

先在目标设置选择城市。只切换你已有的节点;组内其他应用的新连接也可能受影响。节点名称与真实出口分开核对。

未读取控制接口;没有兼容接口时,请在代理客户端手动选择节点。

+
节点地区与恢复记录

先在目标设置选择城市。只切换你已有的节点;组内其他应用的新连接也可能受影响。节点名称与真实出口分开核对。

未读取控制接口;没有兼容接口时,请在代理客户端手动选择节点。

@@ -253,7 +266,7 @@

工具

开源方

仅经指定代理检测 Claude 网页与 API,区分连接失败、需认证、拒绝访问、限流及服务错误。代理失败不回退直连。

环境信息

点击后在下方显示,不会打开额外窗口。设备和浏览器信息用于排障,不推断账号风险。

选择一项查看;无需先安装维护核心。

-
与 claude-switcher 有什么区别

EnvScope 原生提供逐项检查、配置预览与恢复、独立账号资料、加密存档、临时浏览器、托盘和完整软件更新。持续维护提供基线恢复、单独批准的 10 秒进程守护,以及可授权的 Mac 60 秒系统时区守护。

Clash Verge 置顶路由、Mac 系统时区服务和原版加密 DMG 兼容已原生实现。系统网络深度修复、其他客户端适配和旧组件迁移仍需维护核心或手动处理;具体能力与验收范围见环境全览。

上游核对至 223a5cc,已核对其新增 Windows 地区设置;随附源码仍固定在 870c4ca。完整升级尚未完成,当前缺口和验证要求持续记录。

完整功能升级核对表
+
与 claude-switcher 有什么区别

EnvScope 原生提供逐项检查、配置预览与恢复、独立账号资料、加密存档、临时浏览器、托盘和完整软件更新。持续维护提供基线恢复、单独批准的 10 秒进程守护,以及可授权的 Mac 60 秒系统时区守护。

Clash Verge 置顶路由、Mac 系统时区服务和原版加密 DMG 兼容已原生实现。系统网络修复、客户端适配和旧组件迁移提供原生方案;不支持的操作会保留具体手动指引。

上游功能核对至 92cd62f;随附源码固定在 870c4ca。各平台实现方式、需手动处理的项目和验收范围见完整核对表。

完整功能升级核对表

配置备份

最近 20 份可识别恢复记录,关闭后仍保留。撤销会先停用持续维护,并校验备份和当前文件;后续已有修改时拒绝覆盖。原文位于 ~/.envscope/config-backups,可能含密钥,不可分享。

临时浏览器

独立资料,不带入日常 Cookie 或扩展。关闭后清理,应用退出后由独立进程继续检查。

diff --git a/envscope/web/minimal.css b/envscope/web/minimal.css index e86bd7b..20bb4fb 100644 --- a/envscope/web/minimal.css +++ b/envscope/web/minimal.css @@ -2,15 +2,18 @@ [hidden]{display:none!important} :root{color-scheme:light;--paper:#fff;--rail:#f7f7f7;--ink:#202020;--muted:#646464;--line:#e6e6e6;--selected:#eaeaea;--rail-width:192px} body{margin:0;min-width:960px;background:var(--paper);color:var(--ink);font:14px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei UI",sans-serif} -body::before{content:"";position:fixed;inset:0 auto 0 0;width:var(--rail-width);background:var(--rail);border-right:1px solid var(--line);z-index:-1} -header{position:fixed;left:0;top:0;width:var(--rail-width);padding:35px 24px 28px} +.app-header{max-width:1040px;margin:0 auto;padding:20px 40px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)} +header{display:flex;align-items:baseline;gap:14px} header a{color:var(--ink);text-decoration:none;font-size:20px;line-height:1.3;font-weight:650;letter-spacing:-.65px} -header span{display:block;margin-top:7px;font-size:12px;color:var(--muted)} -nav{position:fixed;left:14px;top:141px;width:calc(var(--rail-width) - 28px);display:flex;flex-direction:column;gap:5px} +header span{font-size:12px;color:var(--muted)} +nav{display:flex;align-items:center;gap:18px} +#more-menu{position:relative;margin:0;color:var(--ink);font-size:13px} +#more-menu>summary{padding:8px 0} +.more-menu-items{position:absolute;right:0;top:100%;z-index:10;background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:6px;min-width:190px;display:flex;flex-direction:column} .tab{border:0;border-radius:5px;text-align:left;padding:9px 14px;background:transparent;color:var(--muted);font-size:14px} .tab:hover:not(:disabled){background:#efefef} .tab[aria-current=page]{background:var(--selected);color:var(--ink);font-weight:600} -main{margin-left:var(--rail-width);max-width:1104px;padding:42px 40px 52px;min-height:100vh} +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-banner{border:1px solid var(--ink);border-radius:5px;padding:18px 20px;margin-bottom:24px;max-width:80ch} @@ -40,12 +43,12 @@ button:disabled{opacity:.45;cursor:default} .actions span{font-size:12px;color:var(--muted)} .intro-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:30px} .intro-heading h1{margin:0} -.intro-heading .actions{margin:0;flex-direction:column;align-items:flex-end;gap:7px;min-height:67px} +.intro-heading .actions{margin:0;flex-direction:column;align-items:flex-end;gap:7px} #start{background:var(--ink);border-color:var(--ink);color:#fff;min-width:112px;padding:10px 20px;font-weight:550} #start:hover:not(:disabled){background:#444;border-color:#444} -#mode-note{margin:-15px 185px 0 0;color:var(--muted);font-size:13px;max-width:64ch} +#mode-note{margin:8px 0 0;color:var(--muted);font-size:13px;max-width:64ch} .note,.limits p{font-size:12px;line-height:1.8;color:var(--muted);max-width:80ch} -.introduction{margin-bottom:36px;padding-bottom:27px;border-bottom:1px solid var(--line)} +.introduction{margin-bottom:24px;padding-bottom:0} .network-note{margin-top:12px;max-width:78ch} .network-note p{margin:10px 0 0} .result-summary{display:flex;justify-content:space-between;gap:20px;align-items:baseline} @@ -88,7 +91,8 @@ 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{position:fixed;left:24px;bottom:27px;width:calc(var(--rail-width) - 48px);display:flex;flex-direction:column;gap:13px;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:11px;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} .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} @@ -301,3 +305,60 @@ td .link{margin-left:15px} #checks-plan:empty, #checks-region-recovery:empty { display: none; } #checks-plan { padding: 18px 0; border-bottom: 1px solid #ddd; } #checks-region-options { margin: 18px 0; } + +/* A single desktop workflow; supporting tools stay in the More menu. */ +.connection-status{margin-bottom:16px} +.target-environment{margin-bottom:28px;padding:20px 0} +.target-heading h2{font-size:18px} +#target-form{max-width:none;margin-top:20px} +#target-form .target-primary-fields{gap:14px 24px} +#target-form details{margin:12px 0} +#target-matched{margin:10px 0} +.target-ip{display:flex;align-items:baseline;gap:16px} +.target-ip p{flex:1;margin-bottom:8px} +.target-ip button{font-size:12px} +#target-country-policy{margin-bottom:14px} +#target-form>.actions{margin:18px 0 0} +.flow-progress{display:none} +#flow-state{font-size:12px;margin:12px 0} +#issue-section{margin:24px 0 30px} +#issue-section>h2,.solution-tools>h2,.flow-upkeep h2{font-size:18px} +#issue-summary{margin:7px 0 14px} +.problem{padding:14px 0;margin:0;color:var(--ink)} +.problem>summary{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;width:100%;list-style:none} +.problem>summary::-webkit-details-marker{display:none} +.problem-title{min-width:0;flex:1} +.problem-title strong{display:block;font-size:14px;font-weight:600} +.problem-title .observed{display:block;color:var(--muted);font-size:12px;line-height:1.8;margin:5px 0 0;max-width:82ch;overflow-wrap:anywhere} +.problem-badge{font-size:12px;white-space:nowrap;text-decoration:underline;text-underline-offset:4px;padding-top:1px} +.problem[data-manual=true] .problem-badge{color:#a92828} +.problem[open]{padding-bottom:20px} +.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-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} +.flow-upkeep{margin-top:32px;padding-top:24px} +.flow-upkeep>p{margin:8px 0 14px} +#flow-watchdog-options{margin:14px 0} +#flow-watchdog-options>summary{font-size:13px;color:var(--ink)} +#flow-watchdog-options .checkbox{display:inline-flex;width:auto;margin:8px 24px 5px 0;font-size:13px} +#flow-watchdog-options>label:not(.checkbox){max-width:520px} +.learning-inline{max-width:none;border-left:2px solid var(--line);padding:6px 16px;margin-bottom:24px} +.learning-inline .learning-top{margin:0;float:right;gap:18px} +.learning-inline h1{font-size:14px;letter-spacing:0;margin:0 0 6px} +.learning-inline #learning-body p{font-size:12px;color:var(--muted);max-width:84ch;margin:0} +.learning-inline .actions,.learning-inline .learning-footnote,.learning-inline #learning-progress{display:none} +.learning-inline #learning-skip{font-size:12px} +.inline-upkeep-options .checkbox{margin:0;font-size:13px} +.inline-upkeep-options{gap:14px 24px;margin-top:16px} +.inline-upkeep-options>label:first-child{width:145px} +.plan-notes{margin:14px 0 0}.plan-notes p{margin:10px 0;font-size:12px;line-height:1.8;max-width:82ch} +.result-summary #show-all-checks{margin:0;font-size:12px} +#issue-section{margin-top:14px} +#more-results{border-top:1px solid var(--line);margin-top:28px;padding-top:16px} +#more-results>summary{color:var(--muted);font-size:12px} +#more-results[open]>.try-claude{border-top:0;margin-top:10px} +@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}} diff --git a/envscope/web/region_choices.mjs b/envscope/web/region_choices.mjs index da344d3..31f588f 100644 --- a/envscope/web/region_choices.mjs +++ b/envscope/web/region_choices.mjs @@ -3,6 +3,6 @@ export const countryCode=value=>/^[A-Za-z]{2}$/.test(String(value||'').trim())?S export function cityChoices(profiles,country){return profiles.filter(p=>p.country===countryCode(country));} export function suggestedCity(profiles,observation){ if(observation?.state!=='observed'||!observation.timezone)return null; - const matches=cityChoices(profiles,observation.country).filter(p=>p.iana===observation.timezone); + const matches=cityChoices(profiles,observation.country).filter(p=>p.iana===observation.timezone||p.aliases?.includes(observation.timezone)); return matches.length===1?matches[0]:null; } diff --git a/envscope/web/target.mjs b/envscope/web/target.mjs index ee05c00..bd7d3c9 100644 --- a/envscope/web/target.mjs +++ b/envscope/web/target.mjs @@ -3,9 +3,24 @@ const $=id=>document.getElementById(id); export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ let settings=null,loading=false,saving=false,pendingUsage=null,profiles=[]; - let lookupBusy=false,lookupTimer=null,lookupRevision=0,editRevision=0; + let lookupBusy=false,lookupTimer=null,lookupRevision=0,editRevision=0,manualTarget=false; const form=$('target-form'); const field=name=>form.elements.namedItem(name); + function describeTarget(){ + $('target-matched').textContent=field('locale').value?`地区格式 ${field('locale').value}${field('language').value?',应用语言目标 '+field('language').value:''}。可在下方展开调整。`:'选择城市后自动匹配时区与地区格式。'; + $('target-connection-label').textContent=`${field('usage').selectedOptions[0]?.textContent||'使用方式'},${field('connection_mode').selectedOptions[0]?.textContent||'连接方式'} · 调整连接选项`; + const unsupported=['CN','RU','HK','MO'].includes(countryCode(field('usage_country').value)); + $('target-country-policy').textContent=unsupported?'实际地区未列入 Claude 官方支持名单;改变节点或时区不能使地区条件通过。':'实际使用地区填写所在地,节点地区按出口 IP 建议,可手动调整。'; + $('target-country-policy').classList.toggle('inspection-error',unsupported); + } + function countryOptions(countries){ + if(!countries?.length)return; + const before=field('country').value; + field('country').replaceChildren(new Option('选择节点 / 期望配置地区',''),...countries.map(c=>new Option(c.label+' ('+c.code+')',c.code))); + if(before&&!countries.some(c=>c.code===before))field('country').add(new Option(before,before)); + field('country').value=before; + $('region-codes').replaceChildren(...countries.map(c=>{const option=new Option(c.code,c.code);option.label=c.label;return option;})); + } function showWireguard(value={},saved=field('wireguard_service').value){ const select=field('wireguard_service'); const rows=Array.isArray(value.services)?value.services:[]; @@ -27,6 +42,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ field('region_profile').value=selected.id; for(const k of ['country','timezone','locale'])field(k).value=selected[k]; field('language').value=['desktop','both'].includes(field('usage').value)?selected.language:''; + describeTarget(); } function filterCities(country,{clear=false}={}){ const previous=field('region_profile').value,choices=cityChoices(profiles,country); @@ -34,7 +50,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ const preserved=choices.find(p=>p.id===previous); if(preserved&&!clear)field('region_profile').value=previous; else if(previous||clear)for(const k of ['timezone','locale','language'])field(k).value=''; - return choices; + describeTarget();return choices; } function controls(){ $('target-confirm').disabled=loading||saving||lookupBusy||!!lookupTimer; @@ -55,19 +71,20 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ try{ const value=await run('environment-location',params); if(revision!==lookupRevision||form.hidden)return; - if(edited!==editRevision){$('target-ip-status').textContent='读取期间你修改了目标,已保留手动选择。需要时可重新读取 IP。';return;} + if(edited!==editRevision||manualTarget){$('target-ip-status').textContent=`${value.country?'本次出口 '+value.country+'。':''}已保留手动选择或已保存目标。点“重新读取 IP”可重新匹配。`;return;} const country=countryCode(value.country),actual=countryCode(field('usage_country').value); if(country){ + if(![...field('country').options].some(o=>o.value===country))field('country').add(new Option(country,country)); const changed=field('country').value!==country; field('country').value=country;const choices=filterCities(country,{clear:changed}); const recommended=suggestedCity(profiles,value); if(!field('region_profile').value&&recommended)fillCity(recommended); const mismatch=actual!==country?' 与填写的实际地区不同,请核对;实际地区未被更改。':''; - $('target-ip-status').textContent=`出口 IP:${value.ip||'多个地址'} · ${country}。${value.reason||''} `+(choices.length?`仅显示 ${country} 的 ${choices.length} 个城市。`:'此国家暂无城市预设,请自定义目标。')+(recommended?` 时区对应建议:${recommended.label}。`:' 请选择城市后匹配时区与语言。')+mismatch; + $('target-ip-status').textContent=`出口 IP:${value.ip||'多个地址'} · ${country}。`+(choices.length?`仅显示 ${country} 的 ${choices.length} 个城市 / 时区。`:'此地区暂无映射,可手动填写时区与地区格式。')+(recommended?` 建议:${recommended.label}。`:value.reason||'请选择城市后匹配时区与语言。')+mismatch; }else{ const fallback=countryCode(field('country').value)||actual; filterCities(fallback); - $('target-ip-status').textContent=(value.reason||'IP 未能读取。')+` 当前按手动目标或实际地区 ${fallback} 筛选,尚未根据 IP 匹配;可在“其他目标”修改出口国家。`; + $('target-ip-status').textContent=(value.reason||'IP 未能读取。')+` 当前按 ${fallback} 筛选,尚未根据 IP 匹配;可直接选择节点地区。`; } }catch(error){ if(revision===lookupRevision&&!form.hidden)$('target-ip-status').textContent='IP 读取未完成:'+error.message+'。可手动选择城市,或重新读取。'; @@ -80,11 +97,13 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ $('target-code-fields').hidden=!code; $('target-proxy-field').hidden=field('connection_mode').value!=='http_proxy'; $('target-language-field').hidden=!['desktop','both'].includes(field('usage').value); + describeTarget(); if($('target-language-field').hidden)field('language').value=''; if(!code){field('provider').value='anthropic';field('auth_mode').value='existing';} $('target-wireguard').hidden=field('client').value!=='wireguard'; } function populate(value){ + if(value.country&&![...field('country').options].some(o=>o.value===value.country))field('country').add(new Option(value.country,value.country)); for(const node of form.elements){if(!node.name||!(node.name in value))continue;node.value=Array.isArray(value[node.name])?value[node.name].join('\n'):value[node.name]??'';} if(!field('usage').value)field('usage').value='desktop'; if(!field('connection_mode').value)field('connection_mode').value='direct'; @@ -93,9 +112,9 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ function sync(value){ settings=value; $('target-summary').textContent=value?.environment_confirmed? - `${({desktop:'桌面版',cli:'Code',both:'桌面版与 Code',web:'网页版'})[value.usage]} · ${({direct:'直连',http_proxy:'HTTP / 系统代理',tun:'TUN / VPN'})[value.connection_mode]} · 实际地区 ${value.usage_country||'待确认'} · ${value.timezone||'不比较时区'}`: + `${({desktop:'桌面版',cli:'Code',both:'桌面版与 Code',web:'网页版'})[value.usage]} · ${({direct:'直连',http_proxy:'HTTP / 系统代理',tun:'TUN / VPN'})[value.connection_mode]} · 实际地区 ${value.usage_country||'待确认'} · 节点目标 ${value.country||'仅观察'} · ${value.timezone||'不比较时区'}`: '尚未确认目标。先确认用途与连接方式,再进行有针对性的检查。'; - $('target-edit').textContent=value?.environment_confirmed?'调整目标':'设置目标'; + $('target-edit').textContent=value?.environment_confirmed?'调整地区与时区':'设置地区与时区'; if(value?.environment_confirmed){ $('usage').value=value.usage; $('enhance-cli').checked=['cli','both'].includes(value.usage); @@ -106,7 +125,8 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ async function open(){ if(loading||saving||isBusy())return; if(!await sessionPromise)return; - form.hidden=false;populate(settings||{});$('target-output').textContent=''; + form.hidden=false;manualTarget=!!settings?.environment_confirmed;populate(settings||{});$('target-output').textContent=''; + $('start').hidden=true;$('audit-results').hidden=true; document.dispatchEvent(new CustomEvent('envscope-target-editing')); $('target-cancel').hidden=!settings?.environment_confirmed; loading=true;$('target-confirm').disabled=true; @@ -114,6 +134,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ try{ const value=await run('environment-suggest'); profiles=value.profiles||[]; + countryOptions(value.countries); $('target-region-profile').replaceChildren(new Option('自定义 / 仅观察',''),...profiles.map(p=>new Option(p.label,p.id))); populate(value.proposed||{}); showWireguard(value.wireguard||{},value.proposed?.wireguard_service||''); @@ -124,10 +145,10 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ }catch(error){$('target-observed').textContent='当前设置未能完整读取,可手动填写目标。'+error.message;} finally{loading=false;controls();} if(countryCode(field('usage_country').value))queueLookup(0); - if(!form.hidden&&!$('audit-content').hidden){form.scrollIntoView({block:'nearest'});field('usage').focus();} + if(!form.hidden&&!$('audit-content').hidden){form.scrollIntoView({block:'nearest'});field('usage_country').focus();} } field('usage').addEventListener('change',()=>{visibility();const p=profiles.find(p=>p.id===field('region_profile').value);if(p)fillCity(p);editRevision++;}); - field('connection_mode').addEventListener('change',()=>{visibility();queueLookup();}); + field('connection_mode').addEventListener('change',()=>{manualTarget=false;visibility();queueLookup();}); field('client').addEventListener('change',()=>{ if(field('client').value==='wireguard')field('connection_mode').value='tun'; else field('wireguard_service').value=''; @@ -135,24 +156,26 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ }); $('target-wireguard-read').addEventListener('click',()=>void readWireguard()); field('usage_country').addEventListener('input',()=>{ + manualTarget=false; + describeTarget(); if(!lookupBusy)filterCities(countryCode(field('country').value)||countryCode(field('usage_country').value)); queueLookup(); }); field('target_proxy').addEventListener('input',()=>queueLookup(500)); - $('target-read-ip').addEventListener('click',()=>queueLookup(0)); + $('target-read-ip').addEventListener('click',()=>{manualTarget=false;queueLookup(0);}); field('region_profile').addEventListener('change',()=>{ const selected=profiles.find(p=>p.id===field('region_profile').value); - editRevision++;fillCity(selected); + editRevision++;manualTarget=true;fillCity(selected); $('target-output').textContent=selected?'已填入 '+selected.label+' 的目标值。确认后先检测,修改系统需再审核方案。':'自定义目标;留空的项目只记录。'; }); - for(const k of ['timezone','locale','language'])field(k).addEventListener('input',()=>{editRevision++;if(k!=='language')field('region_profile').value='';}); - field('country').addEventListener('input',()=>{editRevision++;filterCities(field('country').value,{clear:true});$('target-ip-status').textContent='正在使用手动出口国家;选择城市后匹配时区和语言。';}); + for(const k of ['timezone','locale','language'])field(k).addEventListener('input',()=>{editRevision++;manualTarget=true;if(k!=='language')field('region_profile').value='';describeTarget();}); + field('country').addEventListener('change',()=>{editRevision++;manualTarget=true;const choices=filterCities(field('country').value,{clear:true});if(choices.length===1)fillCity(choices[0]);$('target-ip-status').textContent='正在使用手动节点地区;选择城市后匹配时区和语言。';}); $('target-configure').addEventListener('click',()=>{ if(!settings?.environment_confirmed||!form.hidden){void open();$('target-output').textContent='先选择期望地区与城市,确认目标并检测。';return;} document.dispatchEvent(new CustomEvent('envscope-region-configure')); }); $('target-edit').addEventListener('click',()=>void open()); - $('target-cancel').addEventListener('click',()=>{if(!saving&&!loading){form.hidden=true;lookupRevision++;clearTimeout(lookupTimer);lookupTimer=null;controls();$('target-edit').focus();}}); + $('target-cancel').addEventListener('click',()=>{if(!saving&&!loading){form.hidden=true;$('start').hidden=false;$('audit-results').hidden=$('workspace').dataset.state!=='complete';lookupRevision++;clearTimeout(lookupTimer);lookupTimer=null;controls();$('target-edit').focus();}}); form.addEventListener('submit',async event=>{ event.preventDefault();if(loading||saving||lookupBusy||lookupTimer||isBusy())return; saving=true;$('target-confirm').disabled=true;$('start').disabled=true; @@ -164,7 +187,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ if(params.connection_mode!=='http_proxy')params.target_proxy=''; if(params.client!=='wireguard')params.wireguard_service=''; const value=await run('environment-confirm',params); - sync(value.settings);form.hidden=true;completed=true; + sync(value.settings);form.hidden=true;$('start').hidden=false;completed=true; document.dispatchEvent(new CustomEvent('envscope-target-changed',{detail:{settings:value.settings}})); }catch(error){$('target-output').textContent='目标未保存:'+error.message;} finally{saving=false;$('target-confirm').disabled=false;if(!completed)$('start').disabled=false;document.dispatchEvent(new CustomEvent('envscope-operation-state',{detail:{busy:false}}));} @@ -178,7 +201,7 @@ export function initTargetEnvironment({sessionPromise,run,retest,isBusy}){ if(!await sessionPromise)return true; if(loading||saving||lookupBusy||lookupTimer)return false; if(settings?.environment_confirmed&&form.hidden)return true; - if(form.hidden)await open();else{form.scrollIntoView({block:'nearest'});field('usage').focus();} + if(form.hidden)await open();else{form.scrollIntoView({block:'nearest'});field('usage_country').focus();} return false; }}; } diff --git a/envscope/web/workflow.mjs b/envscope/web/workflow.mjs new file mode 100644 index 0000000..dd81904 --- /dev/null +++ b/envscope/web/workflow.mjs @@ -0,0 +1,42 @@ +// Summarize actual failures and missing required evidence without treating either as a pass. +export function workflowIssues(report){ + const findings=new Map((report.findings||[]).map(f=>[f.id,f])); + const annotations=new Map((report.assessment?.items||[]).map(i=>[i.id,i])); + const options=report.repair_options||{}; + const problems=(report.diagnosis?.problems||[]).map(p=>{ + const checks=Object.entries(options).filter(([,v])=>v.problem_ids?.includes(p.id)).map(([id])=>id); + const check=checks.find(id=>annotations.get(id)?.required)||checks[0]||p.id; + const annotation=annotations.get(check),automatic=!!p.repair||checks.some(id=>id!=='baseline.country'&&['region','solution'].includes(options[id]?.action)); + return {...p,check,automatic,required:!!annotation?.required,state:annotation?.state||(p.certainty==='confirmed'?'problem':'review')}; + }); + const known=new Set(problems.map(p=>p.id)); + for(const item of report.assessment?.items||[]){ + if(!item.required||['passed','not_applicable','informational'].includes(item.state)||known.has(item.id))continue; + const mapped=options[item.id]?.problem_ids||[]; + if(mapped.some(id=>known.has(id)))continue; + const finding=findings.get(item.id),remedy=options[item.id]||{}; + problems.push({id:item.id,check:item.id,title:finding?.title||item.title||item.id, + observed:finding?.summary||item.reason||'本轮未取得必需证据',cause:item.reason, + steps:remedy.steps||[item.next_step||'补全本项证据后重新检测。'], + action:item.action==='permission-help'?'permission-help':'retest', + automatic:item.id!=='baseline.country'&&['region','solution'].includes(remedy.action),required:true,state:item.state}); + known.add(item.id); + } + return problems.sort((a,b)=>Number(b.required)-Number(a.required)||(a.priority??2)-(b.priority??2)); +} + +export function workflowChecks(report,enhanceCli=false){ + const findings=new Map((report.findings||[]).map(f=>[f.id,f])); + const annotations=new Map((report.assessment?.items||[]).map(i=>[i.id,i])); + const problems=new Set((report.diagnosis?.problems||[]).map(p=>p.id)); + // The report carries the server-validated selection; never infer permission from UI labels. + return (report.selection?.selected||[]).filter(id=>{ + const finding=findings.get(id),annotation=annotations.get(id),remedy=report.repair_options?.[id]; + if(enhanceCli&&id==='extra.cli_env'&&finding&&finding.scope!=='not-selected'&& + !['not_applicable','unsupported'].includes(annotation?.state)&& + (['cli','both'].includes(report.usage)||report.usage==='auto'&&finding.evidence?.some(e=>e.installation_count||e.config?.exists)))return true; + return finding&&finding.scope!=='not-selected'&&remedy?.needs_attention&& + !['not_applicable','informational','unsupported'].includes(annotation?.state)&& + (annotation?.required||finding.status==='fail'||remedy.problem_ids?.some(p=>problems.has(p))); + }); +} diff --git a/licenses/Unicode-3.0.txt b/licenses/Unicode-3.0.txt new file mode 100644 index 0000000..861b74f --- /dev/null +++ b/licenses/Unicode-3.0.txt @@ -0,0 +1,41 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2004-2025 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 diff --git a/pyproject.toml b/pyproject.toml index 7462cb2..bb1c667 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "envscope" -version = "0.23.0" +version = "0.24.0" description = "Minimal desktop environment checks, guarded accounts and practical maintenance tools" readme = "README.md" requires-python = ">=3.10" license = "MIT" -license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md", "envscope/vendor/claude_switcher/LICENSE"] +license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md", "envscope/vendor/claude_switcher/LICENSE", "licenses/Unicode-3.0.txt"] authors = [{name = "EnvScope contributors"}] dependencies = ["cryptography>=46,<51", "psutil>=7.2,<8", "ruamel.yaml>=0.19.1,<0.20"] classifiers = [ diff --git a/scripts/account_recovery_smoke.py b/scripts/account_recovery_smoke.py index ba8972f..a52c047 100644 --- a/scripts/account_recovery_smoke.py +++ b/scripts/account_recovery_smoke.py @@ -5,6 +5,7 @@ import threading from unittest.mock import patch +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.accounts import AccountManager, _manifest from envscope.operations import Operations @@ -62,7 +63,7 @@ def interrupt(path, text): try: page.goto('http://127.0.0.1:'+str(server.server_port), wait_until='networkidle') if page.locator('#learning-skip').is_visible(): page.locator('#learning-skip').click() - page.locator('.tab[data-page=accounts]').click() + open_page(page, 'accounts') expect(page.locator('#account-recovery')).to_be_visible() expect(page.locator('[data-account=B] button').first).to_be_disabled() before = _manifest(state), _manifest(data) diff --git a/scripts/browser_recovery_smoke.py b/scripts/browser_recovery_smoke.py index cf217cc..b2c1d1c 100644 --- a/scripts/browser_recovery_smoke.py +++ b/scripts/browser_recovery_smoke.py @@ -5,6 +5,7 @@ import threading from unittest.mock import Mock +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.browser_sessions import MARKER @@ -50,7 +51,7 @@ def main(): try: page.goto(f'http://127.0.0.1:{server.server_port}',wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() - page.locator('[data-page=tools]').click() + open_page(page, 'tools') expect(page.locator('#browser-session-status')).to_contain_text('旧版资料 1 个') expect(page.locator('#browser-session-status')).to_contain_text('无法核实 1 个') page.once('dialog',lambda dialog:dialog.dismiss()) diff --git a/scripts/browser_smoke.py b/scripts/browser_smoke.py index 6a61afb..d1e1b98 100644 --- a/scripts/browser_smoke.py +++ b/scripts/browser_smoke.py @@ -4,6 +4,7 @@ import threading import tempfile +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.service import make_server from envscope.model import Finding, Status @@ -116,6 +117,8 @@ def never_run(*args, **kwargs): page.route("https://**/*", lambda route: route.abort()) page.goto(f"http://127.0.0.1:{server.server_port}", wait_until="networkidle") expect(page.locator('#learning-panel')).to_be_visible() + expect(page.locator('#audit-content')).to_be_visible() + page.locator('#learning-replay').click() expect(page.locator('#audit-content')).to_be_hidden() expect(page.locator('#learning-title')).to_have_text('你准备怎样使用 Claude?') before_learning_settings=store.settings() @@ -149,7 +152,7 @@ def never_run(*args, **kwargs): assert '查看详情' not in page.locator('#audit-results').inner_text() assert 'PRIVATE_FIXTURE' not in page.locator('body').inner_text() assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), "horizontal overflow" - page.locator('#technical-details>summary').click() + open_details(page, '#more-results'); page.locator('#technical-details>summary').click() with page.expect_download() as downloaded: page.get_by_role("button", name="导出结果").click() download = downloaded.value @@ -160,17 +163,17 @@ def never_run(*args, **kwargs): assert value["mode"] == "one-click-desktop" assert 'PRIVATE_FIXTURE' not in json.dumps(value) page.evaluate('window.scrollTo(0,0)') - page.locator('#technical-details>summary').click() + open_details(page, '#more-results'); page.locator('#technical-details>summary').click() page.evaluate('window.scrollTo(0,0)') page.screenshot(path=str(ARTIFACTS/f'{label}-diagnosis.png')) - page.locator('#beginner-checks>summary').click() + open_details(page, '#more-results'); page.locator('#beginner-checks>summary').click() page.locator('[data-claude-check="dns"]>summary').click() expect(page.locator('[data-claude-check="dns"]')).to_contain_text('电脑能不能找到 Claude 的服务器') expect(page.locator('[data-claude-check="dns"]')).to_contain_text('能力有限,不等于发现泄漏') expect(page.locator('[data-claude-check="dns"]')).to_contain_text('怎样长期保持') page.locator('#claude-checklist').screenshot(path=str(ARTIFACTS/f'{label}-checklist.png')) - page.locator('#beginner-checks>summary').click() - expect(page.locator('#solution-plan')).to_contain_text('确认后完成这些设置和修复') + open_details(page, '#more-results'); page.locator('#beginner-checks>summary').click() + expect(page.locator('#solution-plan')).to_contain_text('将进行以下改动') assert config_file.read_bytes()==original, 'preview must not edit' page.screenshot(path=str(ARTIFACTS/f'{label}-plan.png')) page.locator('#apply-solution').click() @@ -198,7 +201,7 @@ def never_run(*args, **kwargs): expect(page.locator('#share')).to_be_enabled() expect(page.locator('#start')).to_be_enabled() # Native handoff always rechecks, and uses fixture callbacks only. - expect(page.locator('#desktop-handoff')).to_be_visible() + open_details(page, '#more-results'); expect(page.locator('#desktop-handoff')).to_be_visible() page.once('dialog',lambda dialog:dialog.dismiss()) page.locator('#desktop-launch').click() assert handoff_events == [], 'cancelled handoff cannot launch' @@ -231,14 +234,14 @@ def never_run(*args, **kwargs): page.locator('#start').click() expect(page.locator('#share')).to_be_enabled() expect(page.locator('#apply-solution')).to_have_count(0) - page.locator('#beginner-checks>summary').click() + open_details(page, '#more-results'); page.locator('#beginner-checks>summary').click() page.locator('[data-claude-check="guard"]>summary').click() expect(page.locator('[data-claude-check="guard"]')).to_contain_text('不要求 TUN') expect(page.locator('[data-claude-check="guard"]').get_by_role('button',name='审核本项处理方案',exact=True)).to_have_count(0) - page.get_by_role('button',name='工具',exact=True).click() + open_page(page, 'tools') expect(page.locator('#config-backups')).to_contain_text('Claude 桌面配置') assert 'PRIVATE_FIXTURE' not in page.locator('#config-backups').inner_text() - page.get_by_role('button',name='持续维护',exact=True).click() + open_page(page, 'upkeep') expect(page.locator('#upkeep-state')).to_contain_text('尚未启用') page.locator('#upkeep-restore').check() page.locator('#upkeep-prepare').click() @@ -261,12 +264,12 @@ def never_run(*args, **kwargs): expect(page.locator('#session-state')).to_contain_text('后台原生巡检') assert page.locator('#claude-check-rows [data-claude-check]').count()==14 expect(page.locator('#try-feedback')).to_have_text('尚未由你确认实际回复。') - page.get_by_role('button',name='持续维护',exact=True).click() + open_page(page, 'upkeep') page.get_by_role('button',name='停用并撤销 Claude Code 用户配置',exact=True).first.click() expect(page.locator('#upkeep-state')).to_contain_text('尚未启用') expect(page.locator('#upkeep-output')).to_contain_text('已恢复原配置') assert json.loads(cli_file.read_text(encoding='utf-8'))['env']['HTTPS_PROXY'].endswith(':9999') - page.get_by_role("button", name="账号", exact=True).click() + open_page(page, 'accounts') expect(page.locator("#account-list")).to_contain_text("A") assert page.locator("#account-list tr").count() >= 5 page.locator("#new-slot").fill("work") @@ -275,7 +278,7 @@ def never_run(*args, **kwargs): expect(page.locator("#account-list")).to_contain_text("工作账号") expect(page.locator("#account-output")).to_contain_text("账号名称已保存") page.screenshot(path=str(ARTIFACTS / f"{label}-accounts.png"), full_page=True) - page.get_by_role("button", name="工具", exact=True).click() + open_page(page, 'tools') expect(page.locator('#opensource-options')).to_contain_text('Mihomo') expect(page.locator('#opensource-options')).to_contain_text('Clash Verge Rev') page.locator("#proxy-url").fill("http://127.0.0.1:7897") @@ -284,28 +287,28 @@ def never_run(*args, **kwargs): page.locator("#maintenance-action").select_option("snapshot-export") expect(page.locator("#maintenance-run")).to_be_disabled() page.screenshot(path=str(ARTIFACTS / f"{label}-tools.png"), full_page=True) - page.get_by_role("button", name="设置", exact=True).click() + open_page(page, 'settings') page.locator("#settings-form input[name=country]").fill("US") page.get_by_role("button", name="保存", exact=True).click() expect(page.locator("#settings-output")).to_contain_text("已保存") page.screenshot(path=str(ARTIFACTS / f"{label}-settings.png"), full_page=True) - page.get_by_role("button", name="检测", exact=True).click() + open_page(page, 'audit') page.locator('#start').click() expect(page.locator('#target-form')).to_be_visible() - page.locator('#target-form details>summary').click() - page.locator('#target-form [name=country]').fill('') + open_details(page, '#target-expert-options') + page.locator('#target-form [name=country]').select_option('') page.locator('#target-confirm').click() expect(page.locator("#share")).to_be_enabled() expect(page.locator('html')).to_have_attribute('data-operation-busy','false') expect(page.locator('#start')).to_be_enabled() - page.get_by_role("button", name="历史", exact=True).click() + open_page(page, 'history') page.get_by_role("button", name="比较", exact=True).click() expect(page.locator("#history-output")).to_contain_text("出口证据不足") page.screenshot(path=str(ARTIFACTS / f"{label}-history.png"), full_page=True) - for name in ("检测", "持续维护", "账号", "工具", "历史", "设置"): - page.get_by_role("button", name=name, exact=True).click() + for name in ('audit', 'upkeep', 'accounts', 'tools', 'history', 'settings'): + open_page(page, name) assert page.evaluate("document.documentElement.scrollWidth <= innerWidth"), name + " overflow" - page.get_by_role('button',name='检测',exact=True).click() + open_page(page, 'audit') page.route('**/api/audit',lambda route:route.fulfill(status=503,content_type='application/json',body='{"error":"offline fixture failure"}')) page.locator('#start').click() expect(page.locator('#start')).to_be_enabled() @@ -321,7 +324,7 @@ def never_run(*args, **kwargs): cli_file.write_bytes(code_original) page.locator('#target-edit').click() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#target-form [name=usage]').select_option('cli') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('cli') page.locator('#target-confirm').click() expect(page.locator('#apply-solution')).to_be_visible() expect(page.locator('#solution-plan')).to_contain_text('恢复 Node TLS 证书校验') @@ -331,7 +334,7 @@ def never_run(*args, **kwargs): page.locator('#target-edit').click() expect(page.locator('#target-confirm')).to_be_enabled() assert cli_file.read_bytes()==code_original, 'editing options invalidates pending confirmation' - page.locator('#target-form [name=usage]').select_option('cli') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('cli') page.locator('#target-confirm').click() expect(page.locator('#apply-solution')).to_be_visible() page.locator('#beginner-checks').evaluate('(node)=>node.open=false') @@ -347,7 +350,7 @@ def never_run(*args, **kwargs): assert configured['mcpServers']=={'fixture':{'command':'PRIVATE_MCP'}} assert 'PRIVATE_SETTING' not in page.locator('body').inner_text() assert page.locator('.tab[data-page="audit"]').get_attribute('aria-current')=='page' - page.locator('#flow-upkeep-restore').check() + open_details(page, '#flow-watchdog-options'); page.locator('#flow-upkeep-restore').check() page.locator('#flow-upkeep-prepare').click() expect(page.locator('#flow-upkeep-plan')).to_contain_text('确认启用长期保持') assert not operations.maintenance._config().get('enabled') @@ -358,7 +361,7 @@ def never_run(*args, **kwargs): page.locator('#flow-upkeep-stop').click() expect(page.locator('#flow-upkeep-status')).to_contain_text('尚未启用') assert not operations.maintenance._config().get('enabled') - page.locator('#technical-details>summary').click() + open_details(page, '#more-results'); page.locator('#technical-details>summary').click() with page.expect_download() as configured_download: page.locator('#share').click() safe=Path(configured_download.value.path()).read_text(encoding='utf-8') diff --git a/scripts/check_preferences_smoke.py b/scripts/check_preferences_smoke.py index 020711c..183fb05 100644 --- a/scripts/check_preferences_smoke.py +++ b/scripts/check_preferences_smoke.py @@ -5,6 +5,7 @@ import threading from zipfile import ZipFile +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.client_repair import ClientRepair from envscope.guide import LESSON_VERSION @@ -52,7 +53,7 @@ def external(route): page.route('https://**/*',external) try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') - page.locator('.tab[data-page=settings]').click() + open_page(page, 'settings') page.locator('[name=optional][value=webrtc]').uncheck() page.locator('[name=optional][value=ecosystem]').uncheck() page.locator('#optional-failures').check() @@ -62,7 +63,7 @@ def external(route): page.wait_for_function("document.querySelector('#workspace').dataset.state==='complete'") assert calls and all('extra.ecosystem' not in selected and 'udp.stun' not in selected for selected in calls) assert page.evaluate('window.fixtureICE')==0 - page.locator('.tab[data-page=checks]').click() + open_page(page, 'checks') expect(page.locator('#all-checks-context')).to_contain_text('本次检测') rows={f['id']:f for f in server.last_raw_report['findings']} assert rows['extra.ecosystem']['status']=='skipped' and rows['udp.stun']['status']=='skipped' @@ -85,22 +86,22 @@ def external(route): expect(page.locator('#diagnostic-log-state')).to_contain_text('已关闭') before=len(calls) page.reload(wait_until='networkidle') - page.locator('.tab[data-page=settings]').click() + open_page(page, 'settings') expect(page.locator('[name=optional][value=webrtc]')).not_to_be_checked() expect(page.locator('[name=optional][value=ecosystem]')).not_to_be_checked() expect(page.locator('#optional-failures')).to_be_checked() expect(page.locator('#diagnostic-logging')).not_to_be_checked() assert len(calls)==before # A fresh audit after reload; old evidence is not used for a client action. - page.locator('.tab[data-page=audit]').click();page.locator('#start').click() + open_page(page, 'audit');page.locator('#start').click() expect(page.locator('#start')).to_be_enabled(timeout=45000) - page.locator('.tab[data-page=upkeep]').click();page.locator('#client-load').click() + open_page(page, 'upkeep');page.locator('#client-load').click() expect(page.locator('#client-app')).to_contain_text('FlClash') expect(page.locator('#client-tun')).to_be_disabled() expect(page.locator('#client-policy')).to_have_class('note manual-guidance') page.screenshot(path=str(output/f'{system}-{width}-client.png'),full_page=True) assert io.preference.read_bytes()==original and not io.calls - page.locator('.tab[data-page=settings]').click() + open_page(page, 'settings') page.screenshot(path=str(output/f'{system}-{width}-preferences.png'),full_page=True) assert page.evaluate('document.documentElement.scrollWidth<=innerWidth') assert not errors,errors diff --git a/scripts/client_repair_smoke.py b/scripts/client_repair_smoke.py index 9abcbfa..6c518e7 100644 --- a/scripts/client_repair_smoke.py +++ b/scripts/client_repair_smoke.py @@ -4,6 +4,7 @@ import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.client_repair import ClientRepair from envscope.client_config import tun_model @@ -47,7 +48,7 @@ def audit(*args,**kwargs): page.add_init_script('window.RTCPeerConnection=undefined;') page.route('https://**/*',lambda request:request.abort()) def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) - def upkeep():idle();page.locator('.tab[data-page=upkeep]').click() + def upkeep():idle();open_page(page, 'upkeep') try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() diff --git a/scripts/components_smoke.py b/scripts/components_smoke.py index ea5e189..0e14f56 100644 --- a/scripts/components_smoke.py +++ b/scripts/components_smoke.py @@ -5,6 +5,7 @@ import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright, expect from envscope.legacy_components import ComponentMigration from envscope.operations import Operations @@ -71,7 +72,7 @@ def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) page.locator('#apply-solution').click() expect(page.locator('#solution-plan')).to_contain_text('注册独立的当前用户进程守护',timeout=30000);idle() assert jobs.calls==['retire'] and scheduler.calls==[] and len(audits)>=2 - page.locator('.tab[data-page=upkeep]').click();idle() + open_page(page, 'upkeep');idle() expect(page.locator('#components-state')).to_contain_text('旧任务已移除') page.locator('#components-restore').click();expect(page.locator('#components-plan')).to_contain_text('RunAtLoad') page.locator('#components-plan button').click() @@ -79,7 +80,7 @@ def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) assert jobs.calls==['retire','restore'] and scheduler.calls==[] and not errors proof.append({'system':system,'width':width,'one_stop_stages':True,'new_guard_needs_new_approval':True,'restore':True,'real_jobs_changed':False}) continue - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#components-state')).to_have_text('尚未迁移');idle() expect(page.locator('#components-all')).to_be_checked() page.locator('#components-all').uncheck();expect(page.locator('#components-prepare')).to_be_disabled() diff --git a/scripts/coverage_smoke.py b/scripts/coverage_smoke.py index 3304823..f9b36cd 100644 --- a/scripts/coverage_smoke.py +++ b/scripts/coverage_smoke.py @@ -5,6 +5,7 @@ import tempfile import threading from unittest.mock import Mock, patch +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright, expect from envscope.accounts import AccountManager @@ -65,7 +66,7 @@ def audit(*args,**kwargs): if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('[data-page="coverage"]').click() + open_page(page, 'coverage') expect(page.locator('[data-dimension]')).to_have_count(8) expect(page.locator('[data-feature]')).to_have_count(30) expect(page.locator('[data-feature="components"]')).to_contain_text('原生实现') @@ -78,7 +79,7 @@ def audit(*args,**kwargs): page.screenshot(path=str(out/f'{width}-coverage.png'),full_page=True) page.locator('[data-dimension="device"] button').first.click() expect(page.locator('[data-inspection="device.environment"]')).to_be_visible() - page.locator('[data-page="accounts"]').click() + open_page(page, 'accounts') page.locator('#encrypted-slot').fill('B') page.locator('#encrypted-path').fill(str(home/'fixture.envsnap')) page.locator('#encrypted-password').fill('fixture-ui-password-123') @@ -156,7 +157,7 @@ def audit(*args,**kwargs): expect(page.locator('#account-output')).to_contain_text('已校验并导入',timeout=20000) expect(page.locator('#encrypted-password')).to_have_value('') assert (source/'fixture.txt').read_bytes()==(manager.accounts_dir/'C/fixture.txt').read_bytes() - page.locator('[data-page="tools"]').click() + open_page(page, 'tools') page.locator('#maintenance-action').select_option('open-safe') expect(page.locator('#maintenance-run')).to_be_enabled() page.locator('#maintenance-run').click() diff --git a/scripts/desktop_ui_smoke.py b/scripts/desktop_ui_smoke.py index cb997b4..66d4241 100644 --- a/scripts/desktop_ui_smoke.py +++ b/scripts/desktop_ui_smoke.py @@ -6,6 +6,7 @@ import time from unittest.mock import Mock +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright, expect from envscope.check_catalog import check_catalog from envscope.model import Finding, Status @@ -85,7 +86,7 @@ def audit(*args, **kwargs): page.goto('http://127.0.0.1:' + str(server.server_port), wait_until='networkidle') if page.locator('#learning-panel').is_visible(): page.locator('#learning-skip').click() - page.locator('[data-page="settings"]').click() + open_page(page, 'settings') expect(page.locator('#desktop-mode')).to_contain_text('独立软件正在运行') ids = page.locator('[id]').evaluate_all('(nodes)=>nodes.map(n=>n.id)') assert len(ids) == len(set(ids)) @@ -107,11 +108,12 @@ def audit(*args, **kwargs): page.locator('#app-update-rollback').click() expect(page.locator('#software-output')).to_contain_text('已完成') page.screenshot(path=str(out / f'{width}-settings.png'), full_page=True) - page.locator('[data-page="audit"]').click() + open_page(page, 'audit') page.locator('#start').click() + open_details(page, '#more-results') expect(page.locator('#qualification')).to_be_visible() expect(page.locator('#qualification-counts')).to_contain_text('需要读取权限 1 项') - page.locator('[data-page="checks"]').click() + open_page(page, 'checks') page.locator('#check-status').select_option('unknown') expect(page.locator('[data-inspection="dns.leak"]')).to_have_count(0) expect(page.locator('[data-inspection="extra.cli_paths"]')).to_have_count(0) diff --git a/scripts/generate_region_data.py b/scripts/generate_region_data.py new file mode 100644 index 0000000..83bc202 --- /dev/null +++ b/scripts/generate_region_data.py @@ -0,0 +1,84 @@ +"""Generate offline region presets from a pinned Unicode CLDR release (no runtime fetch).""" +import argparse +import hashlib +import json +from pathlib import Path +import re +from urllib.request import urlopen +import xml.etree.ElementTree as ET + +REVISION = 'acd6d88ae493633240e19a87a721076a8a75c310' # CLDR release-48 +BASE = f'https://raw.githubusercontent.com/unicode-org/cldr/{REVISION}/' +FILES = ('common/supplemental/windowsZones.xml', 'common/supplemental/likelySubtags.xml', + 'common/main/zh.xml', 'common/bcp47/timezone.xml', 'LICENSE') +EXPECTED_SHA256 = dict(zip(FILES, ( + '9cf3db6a31fb382fee21b70be6feba1e82766b0fcd06e6261fb7936a73e537ff', + 'd7732172848cc2870d0d316818190e704bc6614486168dc52af56ee1427b6860', + 'b1ef8bcadcf19fa8914d9ba812a7bcec124c72fecbf4acea02e4c8bd2fe51866', + '54ce7d9f1d385101e24356df7d48ae62cf2acc4421378da60a4007d2488c1aad', + 'b4c0ae8ef04f7059f96ce5bbe0467f9fe6f6d81bbe13517701dfeb961fb4d0b6', +))) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--cache', type=Path, required=True) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + args.cache.mkdir(parents=True, exist_ok=True) + sources, digests = {}, {} + for name in FILES: + cached = args.cache / name.replace('/', '_') + if not cached.exists(): + with urlopen(BASE + name, timeout=30) as response: + cached.write_bytes(response.read(4_000_000)) + content = cached.read_bytes() + sources[name] = content + digests[name] = hashlib.sha256(content).hexdigest() + if digests[name] != EXPECTED_SHA256[name]: + raise ValueError('Pinned CLDR source digest mismatch: ' + name) + zh = ET.fromstring(sources['common/main/zh.xml']) + names = {n.attrib['type']: n.text for n in zh.findall('./localeDisplayNames/territories/territory') + if re.fullmatch('[A-Z]{2}', n.attrib['type']) and not n.attrib.get('alt') + and n.attrib['type'] not in ('ZZ', 'QO', 'EU', 'EZ', 'UN', 'XA', 'XB')} + # Region naming is presentation only, independent of the product availability list. + names.update(CN='中国大陆', HK='中国香港', MO='中国澳门', TW='中国台湾') + cities = {n.attrib['type']: n.findtext('exemplarCity') for n in zh.findall('./dates/timeZoneNames/zone')} + likely = {n.attrib['from']: n.attrib['to'] for n in + ET.fromstring(sources['common/supplemental/likelySubtags.xml']).iter('likelySubtag')} + aliases = {} + for node in ET.fromstring(sources['common/bcp47/timezone.xml']).iter('type'): + zones = node.attrib.get('alias', '').split() + if zones: + for zone in zones: + aliases[zone] = tuple(zones) + windows = ET.fromstring(sources['common/supplemental/windowsZones.xml']) + rows = [] + for node in windows.iter('mapZone'): + country = node.attrib['territory'] + if country not in names: + continue + language = likely.get('und_' + country, '').split('_')[0] + for zone in node.attrib['type'].split(): + city = cities.get(zone) or next((cities[a] for a in aliases.get(zone, ()) if cities.get(a)), None) + city = city or zone.rsplit('/', 1)[-1].replace('_', ' ') + key = country.lower() + '-' + re.sub('[^a-z0-9]+', '-', zone.lower()).strip('-') + locale = language + '-' + country if language else '' + rows.append((key, names[country] + ' · ' + city, country, zone, + node.attrib['other'], locale, aliases.get(zone, (zone,)))) + rows.sort(key=lambda row: (row[2], row[3])) + content = ('"""Generated CLDR 48 region data. Unicode-3.0; see licenses/Unicode-3.0.txt.\n' + 'Regenerate using scripts/generate_region_data.py. Preferences, not location evidence.\n"""\n\n' + f'CLDR_REVISION = {REVISION!r}\n' + f'SOURCE_SHA256 = {digests!r}\n\nCOUNTRIES = (\n' + + ''.join(' ' + repr((code, name)) + ',\n' for code, name in sorted(names.items())) + + ')\n\nREGIONS = (\n' + ''.join(' ' + repr(row) + ',\n' for row in rows) + ')\n') + (root / 'envscope/region_data.py').write_text(content, encoding='utf-8', newline='\n') + (root / 'licenses').mkdir(exist_ok=True) + (root / 'licenses/Unicode-3.0.txt').write_bytes(sources['LICENSE']) + print(json.dumps({'countries': len(names), 'presets': len(rows), + 'countries_with_presets': len({r[2] for r in rows}), 'revision': REVISION})) + + +if __name__ == '__main__': + main() diff --git a/scripts/language_smoke.py b/scripts/language_smoke.py index e0d9652..7767dce 100644 --- a/scripts/language_smoke.py +++ b/scripts/language_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.language_repair import LanguageIO,LanguageRepair @@ -44,13 +45,13 @@ def audit(*args,**kwargs): page=context.new_page();errors=[];page.on('pageerror',lambda error:errors.append(str(error))) page.add_init_script('window.RTCPeerConnection=undefined;');page.route('https://**/*',lambda request:request.abort()) def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) - def upkeep():idle();page.locator('.tab[data-page=upkeep]').click() + def upkeep():idle();open_page(page, 'upkeep') try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click();idle() if system=='Windows': - page.locator('.tab[data-page=checks]').click() + open_page(page, 'checks') row=page.locator('[data-inspection="baseline.language"]');row.locator(':scope > summary').click() expect(row).to_have_attribute('data-manual','true');expect(row).to_contain_text('Windows 尚未提供') upkeep();expect(page.locator('#language-prepare')).to_be_disabled() diff --git a/scripts/process_guard_smoke.py b/scripts/process_guard_smoke.py index 0fe263a..f138564 100644 --- a/scripts/process_guard_smoke.py +++ b/scripts/process_guard_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.operations import Operations from envscope.process_guard import ProcessGuard @@ -40,7 +41,7 @@ def main(): page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click();expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') if mode=='tun': page.locator('#process-guard-load').click() expect(page.locator('#process-guard-interface')).to_contain_text('utun9') diff --git a/scripts/region_lookup_smoke.py b/scripts/region_lookup_smoke.py index 04f6746..f18653f 100644 --- a/scripts/region_lookup_smoke.py +++ b/scripts/region_lookup_smoke.py @@ -4,6 +4,7 @@ import tempfile import threading import time +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.operations import Operations from envscope.service import make_server @@ -36,8 +37,8 @@ def lookup(params): country=page.locator('#target-form [name=usage_country]') city=page.locator('#target-region-profile');status=page.locator('#target-ip-status') country.fill('US');expect(status).to_contain_text('仅显示 US') - assert city.locator('option[value^="us-"]').count()==7 - assert city.locator('option').count()==8 + assert city.locator('option[value^="us-"]').count()>=29 + assert city.locator('option').count()==city.locator('option[value^="us-"]').count()+1 expect(city).to_have_value('us-new-york') expect(page.locator('#target-form [name=timezone]')).to_have_value('Eastern Standard Time' if system=='Windows' else 'America/New_York') expect(page.locator('#target-form [name=language]')).to_have_value('en-US') @@ -58,7 +59,7 @@ def lookup(params): # Stale route response is discarded; the queued route is queried after it. delay[0]=0.4;page.locator('#target-read-ip').click();expect(status).to_contain_text('正在按所选') fixture.update(country='JP',timezone='Asia/Tokyo') - page.locator('#target-form [name=connection_mode]').select_option('tun') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=connection_mode]').select_option('tun') expect(status).to_contain_text('仅显示 JP');expect(page.locator('#target-confirm')).to_be_enabled() assert calls[-1]['connection_mode']=='tun' fixture.clear();fixture.update(state='conflict',reason='模拟来源国家冲突');delay[0]=0 diff --git a/scripts/routing_smoke.py b/scripts/routing_smoke.py index d2a3b97..7ded27d 100644 --- a/scripts/routing_smoke.py +++ b/scripts/routing_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.operations import Operations @@ -36,7 +37,7 @@ def main(): page.goto('http://127.0.0.1:' + str(server.server_port), wait_until='networkidle') if page.locator('#learning-skip').is_visible(): page.locator('#learning-skip').click() page.locator('#start').click(); expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') page.locator('#routing-load').click() expect(page.locator('#routing-group')).to_contain_text('Claude') page.locator('#routing-group').select_option('Claude') @@ -49,21 +50,21 @@ def main(): page.locator('#routing-plan button').click() expect(page.locator('#routing-state')).to_have_text('上次加载已验证', timeout=15000) expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') page.locator('#routing-restore').click() expect(page.locator('#routing-plan')).to_contain_text('恢复此次路由修改前') assert len(core.calls) == 1 page.locator('#routing-plan button').click() expect(page.locator('#routing-state')).to_have_text('已恢复原配置', timeout=15000) expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') page.locator('#routing-prepare').click() expect(page.locator('#routing-plan button')).to_be_visible() core.fail_load = True page.locator('#routing-plan button').click() expect(page.locator('#routing-state')).to_have_text('需要继续恢复', timeout=15000) expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#routing-status')).to_have_class('note manual-guidance') expect(page.locator('#routing-prepare')).to_be_disabled() expect(page.locator('#routing-restore')).to_be_enabled() diff --git a/scripts/selection_smoke.py b/scripts/selection_smoke.py index 766fd95..0870846 100644 --- a/scripts/selection_smoke.py +++ b/scripts/selection_smoke.py @@ -6,6 +6,7 @@ import time from unittest.mock import Mock +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.legacy import LegacyBridge from envscope.model import Status @@ -84,8 +85,8 @@ def block(route): page.goto('http://127.0.0.1:'+str(server.server_port), wait_until='networkidle') page.locator('#learning-skip').click() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#target-form [name=usage]').select_option('cli') - page.locator('#target-form [name=connection_mode]').select_option('tun') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('cli') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=connection_mode]').select_option('tun') page.locator('#target-form [name=usage_country]').fill('US') page.locator('#target-region-profile').select_option('us-new-york') desired = 'Eastern Standard Time' if system == 'Windows' else 'America/New_York' @@ -95,7 +96,7 @@ def block(route): expect(page.locator('#start')).to_be_disabled() expect(page.locator('#start')).to_be_enabled(timeout=30000) expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true') - page.locator('.tab[data-page=checks]').click() + open_page(page, 'checks') page.locator('#check-search').fill('时区') page.locator('#checks-clear').click() page.locator('#checks-select-all').click() @@ -129,8 +130,8 @@ def block(route): expect(row.locator('.inspection-state')).to_contain_text('通过', timeout=30000) expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true') assert writes == [['timezone', desired]] - page.locator('.tab[data-page=audit]').click() - page.locator('#target-configure').click() + open_page(page, 'audit') + open_page(page, 'checks'); page.locator('#target-configure').click() expect(page.locator('#checks-node-note')).to_contain_text('名称不证明') expect(page.locator('#checks-region-undo')).to_be_visible() expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true') diff --git a/scripts/simple_workflow_smoke.py b/scripts/simple_workflow_smoke.py new file mode 100644 index 0000000..14fbc3a --- /dev/null +++ b/scripts/simple_workflow_smoke.py @@ -0,0 +1,144 @@ +"""End-to-end desktop workflow using disposable settings, files and OS adapters.""" +import json +from pathlib import Path +import tempfile +import threading +from unittest.mock import Mock + +from playwright.sync_api import expect, sync_playwright +from envscope.legacy import LegacyBridge +from envscope.maintenance import Maintenance +from envscope.model import Status +from envscope.operations import Operations +from envscope.region_setup import RegionSetup +from envscope.service import make_server +from envscope.state import StateStore +from envscope.watchdog import Watchdog +from scripts.environment_fixtures import fixture +from scripts.ui_navigation import open_page, open_details +from tests.test_maintenance import FakeScheduler + + +def main(): + out = Path('artifacts/simple-workflow-ui') + out.mkdir(parents=True, exist_ok=True) + results = [] + with sync_playwright() as runtime: + browser = runtime.chromium.launch() + try: + for system, width in (('Windows', 1024), ('Windows', 1440), ('Darwin', 1280)): + with tempfile.TemporaryDirectory() as temporary: + home = Path(temporary).resolve() + store = StateStore(home / 'state') + inventory = {'platform': system, 'locale': 'zh-CN', 'network': { + 'timezone_id': 'China Standard Time' if system == 'Windows' else 'Asia/Shanghai'}} + writes, errors, requests = [], [], [] + fault = [None] + + def writer(key, value): + writes.append((key, value)) + if key == 'timezone': + inventory['network']['timezone_id'] = value + else: + inventory[key] = value + + def auditor(*args, **kwargs): + settings = kwargs.get('baseline_config') or store.settings() + rows = fixture(home, settings, system, fault=fault[0]) + for row in rows: + if row.id in ('baseline.timezone', 'baseline.locale'): + key = row.id.split('.')[1] + actual = inventory['network']['timezone_id'] if key == 'timezone' else inventory['locale'] + desired = settings[key] + row.status = Status.PASS if actual.replace('_', '-') == desired.replace('_', '-') else Status.FAIL + row.summary = '符合目标' if row.status == Status.PASS else '当前为 ' + actual + ',目标为 ' + desired + row.evidence = [{'actual': actual, 'expected': desired}] + return rows + + guard = Watchdog(home, system, reader=lambda: inventory, writer=writer, clients=lambda: []) + maintenance = Maintenance(store, home=home, scheduler=FakeScheduler(), watchdog=guard, auditor=auditor) + ops = Operations(store, home, maintenance=maintenance, discoverer=lambda: [], + environment_reader=lambda: inventory, + legacy=LegacyBridge(home=home, runner=Mock(side_effect=AssertionError('No vendor execution'))), + region_lookup=lambda params: {'state': 'observed', 'country': 'US', 'ip': '8.8.8.8', + 'timezone': 'America/New_York', 'reason': '离线模拟'}) + ops.regions = RegionSetup(store, lambda: inventory, home, system, writer) + ops.config_remedies.environ = {} + server = make_server(store=store, operations=ops, auditor=auditor) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + context = browser.new_context(viewport={'width': width, 'height': 950}, locale='zh-CN') + page = context.new_page() + page.on('pageerror', lambda e: errors.append(str(e))) + page.add_init_script('window.RTCPeerConnection=undefined') + page.route('https://**/*', lambda route: route.abort()) + page.on('request', lambda request: requests.append(request.post_data_json) if request.url.endswith('/api/action') else None) + try: + page.goto('http://127.0.0.1:' + str(server.server_port), wait_until='networkidle') + expect(page.locator('#audit-content')).to_be_visible() + expect(page.locator('#learning-panel')).to_be_visible() + expect(page.locator('#audit-results')).to_be_hidden() + assert page.locator('#desktop-nav .tab:visible').count() == 1 + expect(page.locator('#target-confirm')).to_be_enabled() + assert page.locator('#target-country option').count() > 240 + page.screenshot(path=str(out / f'{system}-{width}-initial.png'), full_page=True) + page.locator('#learning-skip').click() + page.locator('[name=usage_country]').fill('US') + expect(page.locator('#target-region-profile')).to_have_value('us-new-york') + open_details(page, '#target-connection-options') + page.locator('#target-form [name=usage]').select_option('cli') + page.locator('#target-form [name=connection_mode]').select_option('direct') + page.locator('#target-connection-options>summary').click() + page.locator('#target-confirm').click() + expect(page.locator('#apply-solution')).to_be_visible(timeout=30000) + expect(page.locator('#solution-plan')).to_contain_text('时区') + assert not writes, 'preview must not write' + assert page.locator('#problems>details').count() >= 2 + assert page.locator('#problems>details>summary:visible').count() == page.locator('#problems>details').count() + 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) + 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() + page.locator('#flow-upkeep-guard-timezone').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 + page.locator('#flow-upkeep-stop').click() + expect(page.locator('#flow-upkeep-status')).to_contain_text('尚未启用') + fault[0] = 'permission' + page.locator('#start').click() + expect(page.locator('#start')).to_be_enabled(timeout=30000) + 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) + for name in ('accounts', 'tools', 'checks', 'upkeep', 'settings', 'coverage', 'history', 'audit'): + open_page(page, name) + expect(page.locator('#page-' + name)).to_be_visible() + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + assert any(r['action'] == 'inspection-plan' for r in requests) + assert all(r['confirmed'] is False for r in requests if r['action'].endswith('-plan')) + assert not errors, errors + 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, + 'advanced_navigation': True, 'production_changes': False}) + finally: + context.close() + server.shutdown() + server.server_close() + thread.join(3) + finally: + browser.close() + (out / 'results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding='utf-8') + print(json.dumps(results, ensure_ascii=False)) + + +if __name__ == '__main__': + main() diff --git a/scripts/software_smoke.py b/scripts/software_smoke.py index 7980b6d..1a11b04 100644 --- a/scripts/software_smoke.py +++ b/scripts/software_smoke.py @@ -5,6 +5,7 @@ import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright,expect from envscope.legacy_components import ComponentMigration from envscope.legacy_software import SoftwareCleanup @@ -49,7 +50,7 @@ def idle():expect(page.locator('#start')).to_be_enabled(timeout=30000) try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#software-state')).to_have_text('尚未清理',timeout=30000);idle() expect(page.locator('#software-all')).to_be_checked() expect(page.locator('#software-status')).to_have_class('note manual-guidance') diff --git a/scripts/system_network_smoke.py b/scripts/system_network_smoke.py index ec2dd04..ddc74c3 100644 --- a/scripts/system_network_smoke.py +++ b/scripts/system_network_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.operations import Operations @@ -50,7 +51,7 @@ def audit(*args,**kwargs): page.route('https://**/*',lambda request:request.abort()) def upkeep(): expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') try: page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() diff --git a/scripts/target_environment_smoke.py b/scripts/target_environment_smoke.py index d21b555..70562cc 100644 --- a/scripts/target_environment_smoke.py +++ b/scripts/target_environment_smoke.py @@ -5,6 +5,7 @@ import threading from unittest.mock import Mock +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.legacy import LegacyBridge from envscope.operations import Operations @@ -56,15 +57,16 @@ def block(route): page.locator('#learning-skip').click() expect(page.locator('#target-form')).to_be_visible() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#start').click() + expect(page.locator('#start')).to_be_hidden() + page.locator('#target-confirm').click() assert not calls and not outbound and code.read_bytes() == original token = context.request.get(origin+'/api/session').json()['token'] snapshot = {'schema_version':1,'kind':'envscope-browser-observations','contains_sensitive':True, 'created_at':utc_now(),'http':[],'ice':[],'ice_complete':False} denied = context.request.post(origin+'/api/audit', data={'snapshot':snapshot}, headers={'Origin':origin,'X-EnvScope-Token':token}) assert denied.status == 409 and not calls - page.locator('#target-form [name=usage]').select_option('cli') - page.locator('#target-form [name=connection_mode]').select_option('direct') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('cli') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=connection_mode]').select_option('direct') expect(page.locator('#target-form [name=timezone]')).to_have_value('') expect(page.locator('#target-form [name=locale]')).to_have_value('') expect(page.locator('#target-form [name=usage_country]')).to_have_value('') @@ -111,20 +113,20 @@ def block(route): expect(page.locator('#apply-solution')).to_have_count(0) page.locator('#target-edit').click() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#target-form [name=connection_mode]').select_option('http_proxy') - page.locator('#target-form [name=target_proxy]').fill(PROXY) + open_details(page, '#target-connection-options'); page.locator('#target-form [name=connection_mode]').select_option('http_proxy') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=target_proxy]').fill(PROXY) page.locator('#target-cancel').click() assert store.settings()['connection_mode'] == 'direct' page.locator('#target-edit').click() expect(page.locator('#target-confirm')).to_be_enabled() - page.locator('#target-form [name=usage]').select_option('web') + open_details(page, '#target-connection-options'); page.locator('#target-form [name=usage]').select_option('web') page.locator('#target-confirm').click() expect(page.locator('#target-form')).to_be_hidden() expect(page.locator('#target-summary')).to_contain_text('网页版') expect(page.locator('#verdict')).to_have_text('所选目标的环境检查合格') assert code.read_bytes() == repaired, 'web selection must not rewrite Code' assert store.settings()['usage'] == 'web' - page.locator('[data-page=checks]').click() + open_page(page, 'checks') row = page.locator('[data-inspection="extra.cli_paths"]') expect(row).to_contain_text('不适用') page.locator('#check-status').select_option('excluded') diff --git a/scripts/timezone_service_smoke.py b/scripts/timezone_service_smoke.py index 8c54b8d..5142a2b 100644 --- a/scripts/timezone_service_smoke.py +++ b/scripts/timezone_service_smoke.py @@ -4,6 +4,7 @@ import tempfile import threading import time +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.operations import Operations @@ -42,7 +43,7 @@ def main(): page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click();expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('尚未授权') page.locator('#timezone-service-prepare').click() expect(page.locator('#timezone-service-plan')).to_contain_text('Asia/Shanghai 配置为 America/New_York') @@ -50,20 +51,20 @@ def main(): page.locator('#timezone-service-panel').screenshot(path=str(output/f'{width}-preview.png')) page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('系统时区守护正常',timeout=10000) assert len(io.scripts)==1 page.locator('#timezone-service-undo').click() expect(page.locator('#timezone-service-plan')).to_contain_text('Asia/Shanghai') page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('尚未授权') assert world['timezone_iana']=='Asia/Shanghai' page.locator('#timezone-service-prepare').click() page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('系统时区守护正常') io.interrupt=True page.locator('#timezone-service-prepare').click() @@ -77,17 +78,17 @@ def main(): expect(page.locator('#timezone-service-plan')).to_contain_text('恢复上次保存的服务文件') page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('系统时区守护正常') page.locator('#timezone-service-restore').check() page.locator('#timezone-service-stop').click() expect(page.locator('#timezone-service-plan')).to_contain_text('恢复安装前的系统时区:Asia/Shanghai') page.locator('#timezone-service-plan button').click() expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#timezone-service-state')).to_have_text('尚未授权') ops.timezone_service.system='Windows' - page.reload(wait_until='networkidle');page.locator('.tab[data-page=upkeep]').click() + page.reload(wait_until='networkidle');open_page(page, 'upkeep') expect(page.locator('#timezone-service-panel')).to_be_hidden() assert not errors,errors assert page.evaluate('document.documentElement.scrollWidth<=innerWidth') diff --git a/scripts/ui_navigation.py b/scripts/ui_navigation.py new file mode 100644 index 0000000..e633a7c --- /dev/null +++ b/scripts/ui_navigation.py @@ -0,0 +1,14 @@ +"""Use the actual visible menu in desktop UI acceptance tests.""" +from playwright.sync_api import expect + + +def open_page(page, name): + expect(page.locator('html')).not_to_have_attribute('data-operation-busy', 'true', timeout=30000) + if name != 'audit' and not page.locator('#more-menu').evaluate('(node)=>node.open'): + page.locator('#more-menu>summary').click() + page.locator(f'.tab[data-page="{name}"]').click() + + +def open_details(page, selector): + if not page.locator(selector).evaluate('(node)=>node.open'): + page.locator(selector + '>summary').click() diff --git a/scripts/usability_smoke.py b/scripts/usability_smoke.py index be47def..5c197f5 100644 --- a/scripts/usability_smoke.py +++ b/scripts/usability_smoke.py @@ -6,6 +6,7 @@ import time from unittest.mock import patch +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import sync_playwright, expect from envscope.accounts import AccountManager from envscope.check_catalog import check_catalog @@ -71,7 +72,7 @@ def audit(*args, **kwargs): if page.locator('#learning-panel').is_visible(): page.locator('#learning-skip').click() expect(page.locator('#connection-status')).to_contain_text('已连接') - page.get_by_role('button', name='全部检查', exact=True).click() + open_page(page, 'checks') expect(page.locator('[data-inspection]')).to_have_count(57) expect(page.locator('#all-checks-context')).to_contain_text('尚未检测') page.locator('#check-group').select_option('baseline') @@ -95,7 +96,7 @@ def audit(*args, **kwargs): page.screenshot(path=str(OUT / f'{width}-checks.png')) page.locator('#check-group').select_option('all') - page.get_by_role('button', name='工具', exact=True).click() + open_page(page, 'tools') with patch('envscope.operations.cli_information', side_effect=lambda path: cli_information(path, which=lambda n: None)): page.locator('#cli-info').click() expect(page.locator('#environment-output')).to_contain_text('当前进程 PATH 中未找到') @@ -115,7 +116,7 @@ def audit(*args, **kwargs): payload = json.loads(Path(downloaded.value.path()).read_text(encoding='utf-8')) assert payload['contains_sensitive'] is False - page.get_by_role('button', name='账号', exact=True).click() + open_page(page, 'accounts') expect(page.locator('.account-guide')).to_contain_text('不是邮箱或已注册账号') expect(page.locator('#account-force-close')).not_to_be_checked() page.locator('#new-slot').fill('错误代号') @@ -156,21 +157,21 @@ def approve_force(dialog): assert (manager.data_dir / 'fixture.txt').read_text() == 'ACCOUNT_A_FIXTURE' assert (manager.accounts_dir / 'B/fixture.txt').read_text() == 'ACCOUNT_B_FIXTURE' page.screenshot(path=str(OUT / f'{width}-accounts.png'), full_page=True) - page.get_by_role('button', name='全部检查', exact=True).click() + open_page(page, 'checks') page.locator('#check-status').select_option('pass') assert page.locator('[data-inspection]').count() > 0 page.locator('#check-status').select_option('all') expect(page.locator('#all-checks-context')).to_contain_text('本次检测') - page.get_by_role('button', name='历史', exact=True).click() + open_page(page, 'history') page.locator('#history-list').get_by_role('button', name='查看', exact=True).first.click() - page.get_by_role('button', name='全部检查', exact=True).click() + open_page(page, 'checks') expect(page.locator('#all-checks-context')).to_contain_text('历史报告') - page.get_by_role('button', name='检测', exact=True).click() + open_page(page, 'audit') page.route('**/api/audit', lambda route: route.fulfill(status=503, content_type='application/json', body='{"error":"fixture_failure"}')) page.locator('#start').click() expect(page.locator('#verdict')).to_contain_text('系统检测没有完成') - page.get_by_role('button', name='全部检查', exact=True).click() + open_page(page, 'checks') expect(page.locator('#all-checks-context')).to_contain_text('本轮检测没有完成') page.locator('#check-status').select_option('pass') expect(page.locator('[data-inspection]')).to_have_count(0) @@ -178,7 +179,7 @@ def approve_force(dialog): expect(page.locator('[data-inspection]')).to_have_count(57) page.unroute('**/api/audit') - page.get_by_role('button', name='工具', exact=True).click() + open_page(page, 'tools') page.route('**/api/action', lambda route: route.fulfill(status=500, content_type='application/json', body='{"error":"operation_unavailable_or_deadline"}')) page.locator('#device-info').click() expect(page.locator('#environment-output')).to_contain_text('本次读取未能完成') @@ -197,12 +198,12 @@ def approve_force(dialog): expect(page.locator('#connection-banner')).to_be_hidden() page.locator('#browser-info').click() expect(page.locator('#environment-output')).to_contain_text('浏览器信息') - for name in ('检测', '全部检查', '账号', '工具', '持续维护', '历史', '设置'): - page.get_by_role('button', name=name, exact=True).click() + for name in ('audit', 'checks', 'accounts', 'tools', 'upkeep', 'history', 'settings'): + open_page(page, name) assert page.evaluate('document.documentElement.scrollWidth <= innerWidth'), name - page.get_by_role('button', name='工具', exact=True).click() + open_page(page, 'tools') page.locator('#project-comparison>summary').click() - expect(page.locator('#project-comparison')).to_contain_text('仍需维护核心或手动处理') + expect(page.locator('#project-comparison')).to_contain_text('完整功能升级核对表') # A visible, idle page renews activity without running diagnostics. page.clock.install() server.last_activity = time.monotonic() - 901 @@ -214,7 +215,7 @@ def approve_force(dialog): page.route('**/api/catalog', lambda route: route.fulfill(status=500, content_type='application/json', body='{"error":"catalog_unavailable"}')) page.reload(wait_until='networkidle') expect(page.locator('#catalog-error')).to_be_visible() - page.get_by_role('button', name='工具', exact=True).click() + open_page(page, 'tools') with patch('envscope.operations.cli_information', side_effect=lambda path: cli_information(path, which=lambda n: None)): page.locator('#cli-info').click() expect(page.locator('#environment-output')).to_contain_text('当前进程 PATH 中未找到') diff --git a/scripts/verify_package.py b/scripts/verify_package.py index 9e7143e..e4cbc24 100644 --- a/scripts/verify_package.py +++ b/scripts/verify_package.py @@ -25,6 +25,7 @@ def verify(read, names): assert read("envscope/web/" + path), "missing UI resource" assert read('envscope/web/components_ui.mjs'), 'missing component migration UI' assert read('envscope/web/check_preferences_ui.mjs'), 'missing optional checks / diagnostics UI' + assert read('envscope/web/workflow.mjs'), 'missing single-workflow UI' print("Verified full pinned vendor bundle and all desktop UI resources.") @@ -39,6 +40,8 @@ def main(): return with zipfile.ZipFile(target) as wheel: assert wheel.read('envscope/csnap_archive.py'), 'missing CSNAP001 compatibility module' + assert wheel.read('envscope/region_data.py'), 'missing offline CLDR region presets' + assert any(n.endswith('/licenses/licenses/Unicode-3.0.txt') for n in wheel.namelist()), 'missing Unicode license' metadata=[n for n in wheel.namelist() if n.endswith('.dist-info/METADATA')] assert len(metadata)==1,'Missing or ambiguous package metadata' values=BytesParser().parsebytes(wheel.read(metadata[0])) diff --git a/scripts/watchdog_smoke.py b/scripts/watchdog_smoke.py index 0d867e9..bbdaf31 100644 --- a/scripts/watchdog_smoke.py +++ b/scripts/watchdog_smoke.py @@ -3,6 +3,7 @@ from pathlib import Path import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect,sync_playwright from envscope.maintenance import Maintenance from envscope.operations import Operations @@ -46,7 +47,7 @@ def writer(key,value): page.goto('http://127.0.0.1:'+str(server.server_port),wait_until='networkidle') if page.locator('#learning-skip').is_visible():page.locator('#learning-skip').click() page.locator('#start').click();expect(page.locator('#start')).to_be_enabled(timeout=30000) - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') expect(page.locator('#upkeep-egress')).to_contain_text('尚无有效出口观测') page.locator('#upkeep-guard-timezone').check() page.locator('#upkeep-prepare').click();expect(page.locator('#upkeep-plan')).to_contain_text('保持时区') diff --git a/scripts/wireguard_smoke.py b/scripts/wireguard_smoke.py index 4ea5fe5..99efd0b 100644 --- a/scripts/wireguard_smoke.py +++ b/scripts/wireguard_smoke.py @@ -7,6 +7,7 @@ import tempfile import threading +from scripts.ui_navigation import open_page, open_details from playwright.sync_api import expect, sync_playwright from envscope.operations import Operations from envscope.model import Status @@ -55,7 +56,7 @@ def auditor(*args, **kw): page.add_init_script('window.RTCPeerConnection=undefined;'); page.route('https://**/*', lambda r: r.abort()) def idle(): expect(page.locator('#start')).to_be_enabled(timeout=30000) def all_checks(): - idle(); page.locator('.tab[data-page=checks]').click() + idle(); open_page(page, 'checks') return page.locator('[data-inspection="target.wireguard"]') try: page.goto('http://127.0.0.1:'+str(server.server_port), wait_until='networkidle') @@ -63,6 +64,7 @@ def all_checks(): page.locator('#target-edit').click() expect(page.locator('#target-observed')).to_contain_text('Windows') expect(page.locator('#target-confirm')).to_be_enabled(timeout=15000) + open_details(page, '#target-connection-options') page.locator('#target-form select[name=client]').select_option('wireguard') expect(page.locator('#target-wireguard')).to_be_visible() expect(page.locator('#target-form select[name=connection_mode]')).to_have_value('tun') @@ -78,7 +80,7 @@ def all_checks(): assert not scheduler.calls and not io.stops # Manual controls also select the single matching NIC, never # offer another active VPN as a substitute for this service. - page.locator('.tab[data-page=upkeep]').click() + open_page(page, 'upkeep') page.locator('#process-guard-load').click() expect(page.locator('#process-guard-interface')).to_have_value('EnvScope Fixture US') expect(page.locator('#process-guard-interface')).not_to_contain_text('Other VPN') @@ -95,16 +97,16 @@ def all_checks(): guard.pulse(); page.locator('#process-guard-refresh').click() expect(page.locator('#process-guard-status')).to_contain_text('所选 WireGuard 隧道服务离线') assert len(io.stops) == 1 - page.locator('.tab[data-page=audit]').click(); page.locator('#start').click() + open_page(page, 'audit'); page.locator('#start').click() expect(all_checks()).to_contain_text('所选 WireGuard 隧道服务未运行') page.locator('[data-inspection="target.wireguard"]').screenshot(path=str(output/f'{width}-offline.png')) services['services'][0].update(observed()['services'][0]); route[0] = 'Other VPN' - page.locator('.tab[data-page=audit]').click(); page.locator('#start').click(); all_checks() + open_page(page, 'audit'); page.locator('#start').click(); all_checks() expect(page.locator('[data-inspection="target.tun_route"]')).to_contain_text('目标地址存在非隧道选路') services['services'][0]['error'] = 'permission_denied' - page.locator('.tab[data-page=audit]').click(); page.locator('#start').click() + open_page(page, 'audit'); page.locator('#start').click() expect(all_checks()).to_contain_text('系统拒绝读取所选 WireGuard 服务或进程') - page.locator('.tab[data-page=upkeep]').click(); page.locator('#process-guard-stop').click() + open_page(page, 'upkeep'); page.locator('#process-guard-stop').click() expect(page.locator('#process-guard-state')).to_have_text('未启用') assert not scheduler.definition and not errors assert page.evaluate('document.documentElement.scrollWidth<=innerWidth') diff --git a/tests/browser.test.mjs b/tests/browser.test.mjs index 3881504..d1db6b4 100644 --- a/tests/browser.test.mjs +++ b/tests/browser.test.mjs @@ -1,8 +1,55 @@ import test from "node:test"; import {describeProxy} from "../envscope/web/diagnosis.mjs"; import assert from "node:assert/strict"; +import {workflowIssues,workflowChecks} from '../envscope/web/workflow.mjs'; +import {suggestedCity,cityChoices} from '../envscope/web/region_choices.mjs'; import {normalizeIP,isPublicIP,parseCandidate,assessHTTP,assessICE,sharingSnapshot} from "../envscope/web/logic.mjs"; +test('required permission and missing evidence stay visible without a diagnosis problem',()=>{ + const report={findings:[{id:'target.config',title:'配置读取',summary:'权限被拒绝'}],diagnosis:{problems:[]}, + assessment:{items:[{id:'target.config',required:true,state:'permission',action:'permission-help',next_step:'授权后重试'}, + {id:'target.dns',title:'域名解析',required:true,state:'unavailable',next_step:'重新检测'}, + {id:'dns.leak',required:false,state:'unsupported'}]}}; + const issues=workflowIssues(report); + assert.deepEqual(issues.map(i=>i.id),['target.config','target.dns']); + assert.equal(issues[0].action,'permission-help');assert.equal(issues[0].automatic,false); + assert.equal(issues[1].observed,'本轮未取得必需证据'); +}); + +test('one problem mapped by several required checks is not repeated',()=>{ + const report={diagnosis:{problems:[{id:'config.cli',repair:'json-cli',title:'配置格式错误'}]}, + assessment:{items:[{id:'target.config',required:true,state:'problem'}]}, + repair_options:{'target.config':{action:'solution',problem_ids:['config.cli']},'extra.config':{action:'solution',problem_ids:['config.cli']}}}; + assert.equal(workflowIssues(report).length,1);assert.equal(workflowIssues(report)[0].automatic,true); +}); + +test('one-click repair excludes passed, unselected and informational checks',()=>{ + const ids=['baseline.timezone','baseline.guard','target.dns','extra.config']; + const report={selection:{selected:ids},findings:[{id:ids[0],status:'warn'}, + {id:ids[1],status:'unknown'},{id:ids[2],status:'fail',scope:'not-selected'},{id:ids[3],status:'pass'}], + assessment:{items:[{id:ids[0],required:true,state:'review'},{id:ids[1],required:false,state:'informational'}]}, + repair_options:Object.fromEntries(ids.map(id=>[id,{needs_attention:id!==ids[3],action:'solution',problem_ids:[]}]))}; + assert.deepEqual(workflowChecks(report),['baseline.timezone']); +}); + +test('IP timezone aliases are country-bound and partial lookup never auto-selects',()=>{ + const india={id:'india',country:'IN',iana:'Asia/Calcutta',aliases:['Asia/Calcutta','Asia/Kolkata']}; + const observation={state:'observed',country:'IN',timezone:'Asia/Kolkata'}; + assert.equal(suggestedCity([india],observation),india); + assert.equal(suggestedCity([india],{...observation,state:'partial'}),null); + assert.equal(suggestedCity([india],{...observation,country:'US'}),null); + assert.deepEqual(cityChoices([india],'US'),[]); +}); + +test('reviewed Code setup keeps measured scope and excludes web-only usage',()=>{ + const report={usage:'cli',selection:{selected:['extra.cli_env']},findings:[{id:'extra.cli_env',status:'pass'}]}; + assert.deepEqual(workflowChecks(report),[]); + assert.deepEqual(workflowChecks(report,true),['extra.cli_env']); + assert.deepEqual(workflowChecks({...report,usage:'web'},true),[]); + assert.deepEqual(workflowChecks({...report,selection:{selected:[]}},true),[]); + assert.deepEqual(workflowChecks({...report,findings:[{id:'extra.cli_env',scope:'not-selected'}]},true),[]); +}); + test("IPv6 equivalent spellings and mapped IPv4 canonicalize",()=>{ assert.equal(normalizeIP("2001:4860::1"),normalizeIP("2001:4860:0:0:0:0:0:1")); assert.equal(normalizeIP("::ffff:8.8.8.8"),"8.8.8.8"); diff --git a/tests/test_region_catalog.py b/tests/test_region_catalog.py new file mode 100644 index 0000000..fc4d11b --- /dev/null +++ b/tests/test_region_catalog.py @@ -0,0 +1,32 @@ +from unittest import TestCase + +from envscope.region_profiles import countries, profiles, profile, validate_profile + + +class RegionCatalogTests(TestCase): + def test_common_node_countries_have_real_timezone_mappings_on_both_platforms(self): + expected = {'NL', 'IE', 'CH', 'IT', 'ES', 'SE', 'FI', 'NO', 'PL', 'IN', 'BR', + 'ZA', 'TH', 'MY', 'NZ', 'ID', 'AE', 'PH', 'VN', 'CN', 'RU'} + self.assertTrue(expected <= {c['code'] for c in countries()}) + for system in ('Windows', 'Darwin'): + rows = profiles(system) + self.assertGreaterEqual(len(rows), 400) + self.assertEqual(len(rows), len({r['id'] for r in rows})) + self.assertTrue(expected <= {r['country'] for r in rows}) + for row in rows: + validate_profile(dict(region_profile=row['id'], **{k: row[k] for k in ('country', 'timezone', 'locale')})) + self.assertNotIn('usage_country', row) + + def test_saved_ids_are_stable_and_timezone_aliases_are_available(self): + self.assertEqual(profile('us-new-york', 'Windows')['timezone'], 'Eastern Standard Time') + self.assertEqual(profile('sg-singapore', 'Darwin')['locale'], 'en_SG') + india = [r for r in profiles('Windows') if r['country'] == 'IN'] + self.assertTrue(any('Asia/Kolkata' in r['aliases'] and r['timezone'] == 'India Standard Time' for r in india)) + self.assertTrue(any(r['iana'] == 'Europe/Amsterdam' and r['locale'] == 'nl-NL' for r in profiles('Windows'))) + + def test_adding_region_choices_does_not_make_unsupported_actual_location_pass(self): + from envscope.regions import region_finding + for code in ('CN', 'RU'): + self.assertTrue(any(p['country'] == code for p in profiles('Windows'))) + # The separately maintained service policy remains authoritative. + self.assertEqual(region_finding([], {'usage_country': code, 'country': 'US'}).status.value, 'fail') diff --git a/tests/test_region_selection.py b/tests/test_region_selection.py index 3b2dbee..741b44f 100644 --- a/tests/test_region_selection.py +++ b/tests/test_region_selection.py @@ -199,6 +199,40 @@ def test_select_all_can_prepare_without_irrelevant_target_rows_blocking_it(self) self.assertTrue(value['manual_items']) self.assertFalse(self.writes) + def test_combined_plan_repairs_json_and_code_security_in_one_review(self): + ops = Operations(self.store, self.home, discoverer=lambda: [], environment_reader=lambda: self.inventory) + ops.config_remedies.environ = {} + self.report() + config = self.home / '.claude/settings.json' + original = b'{"env":{"NODE_TLS_REJECT_UNAUTHORIZED":"0"},"permissions":{"deny":["Read(.env)"]},}' + config.write_bytes(original) + report = self.report() + params = {'report_id': report['report_id'], 'check_ids': ['extra.config', 'extra.cli_env'], 'enhance_cli': True} + plan = ops.run('inspection-plan', params, report=report) + self.assertEqual(plan['state'], 'planned') + self.assertTrue(any('TLS' in effect for effect in plan['effects'])) + self.assertEqual(config.read_bytes(), original) + result = ops.run('inspection-apply', {'plan_id': plan['plan_id']}, confirmed=True, report=report) + self.assertEqual(result['state'], 'executed') + self.assertTrue(result['needs_retest']) + self.assertTrue(result['before']['configuration']) + content = json.loads(config.read_bytes()) + self.assertEqual(content['env']['NODE_TLS_REJECT_UNAUTHORIZED'], '1') + self.assertEqual(content['permissions'], {'deny': ['Read(.env)']}) + + def test_combined_code_enhancement_requires_measured_code_scope(self): + ops = Operations(self.store, self.home, discoverer=lambda: [], environment_reader=lambda: self.inventory) + report = self.report(['baseline.timezone']) + params = {'report_id': report['report_id'], 'check_ids': ['baseline.timezone'], 'enhance_cli': True} + with self.assertRaises(ValueError): + ops.run('inspection-plan', params, report=report) + with self.assertRaises(ValueError): + ops.run('inspection-plan', {**params, 'enhance_cli': 'true'}, report=report) + with self.assertRaises(ValueError): + ops.solutions.prepare(report, check_ids=['baseline.timezone'], enhance_cli=True) + self.assertEqual(ops.run('inspection-plan', {**params, 'check_ids': ['extra.cli_env']}, report=report)['state'], 'retest_required') + self.assertFalse(self.writes) + def test_pending_region_recovery_is_not_replaced_by_another_plan(self): report = self.report() plan = self.region.prepare(report, ['timezone'])