diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index faaf9df..0d6ff72 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -28,7 +28,10 @@ jobs: - name: Detached browser cleanup after parent exit (owned processes only) if: runner.os != 'Linux' run: python -m scripts.browser_session_acceptance - - run: node --test tests/browser.test.mjs + - run: node --test tests/browser.test.mjs tests/permission_flow.test.mjs + - run: node --check envscope/web/permission_flow.mjs + - run: node --check envscope/web/workspace_ui.mjs + - run: node --check envscope/web/icons.mjs - run: node --check envscope/web/app.mjs - run: node --check envscope/web/controls.mjs - run: node --check envscope/web/diagnosis.mjs @@ -62,6 +65,8 @@ jobs: - run: python -m scripts.browser_smoke - run: python -m scripts.usability_smoke - run: python -m scripts.desktop_ui_smoke + - run: python -m scripts.desktop_refresh_smoke + - run: python -m scripts.permission_flow_smoke - run: python -m scripts.target_environment_smoke - run: python -m scripts.coverage_smoke - run: python -m scripts.browser_recovery_smoke diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 17aed4a..a42dd83 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -44,6 +44,11 @@ jobs: env: ENVSCOPE_TIMEZONE_ACCEPTANCE: '1' run: python -m scripts.timezone_acceptance + - name: Actual SYSTEM timezone task lifecycle (fixture clock only) + if: runner.os == 'Windows' + env: + ENVSCOPE_TIMEZONE_ACCEPTANCE: '1' + run: python -m scripts.windows_timezone_acceptance - name: Actual local Mihomo routing and rollback (isolated fixture) run: python -m scripts.routing_core_smoke - name: Actual native client lifecycle (owned disposable GUI only) @@ -121,7 +126,9 @@ jobs: if: always() with: name: Timezone-evidence-${{ runner.os }}-${{ runner.arch }} - path: artifacts/timezone-acceptance/result.json + path: | + artifacts/timezone-acceptance/result.json + artifacts/windows-timezone-acceptance.json if-no-files-found: warn - uses: actions/upload-artifact@v4 if: always() diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..b21db2e --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,124 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-10-30T10:37:02.2768526Z", + "title": "Design System: EnvScope", + "extensions": { + "colorMeta": { + "paper": { "role": "neutral", "displayName": "主画布" }, + "rail": { "role": "neutral", "displayName": "侧栏与筛选底色" }, + "ink": { "role": "primary", "displayName": "正文与主操作" }, + "muted": { "role": "neutral", "displayName": "辅助文字" }, + "line": { "role": "neutral", "displayName": "分隔线" }, + "selected": { "role": "neutral", "displayName": "当前导航" }, + "panel": { "role": "neutral", "displayName": "目标与守护摘要" }, + "expanded": { "role": "neutral", "displayName": "展开行" }, + "danger": { "role": "primary", "displayName": "手动说明与检查错误" }, + "manual-danger": { "role": "primary", "displayName": "现有手动徽标与左边线" } + }, + "typographyMeta": { + "headline": { "displayName": "页面标题", "purpose": "主页面标题。" }, + "title": { "displayName": "分节标题", "purpose": "常规功能分节,继承系统字体。" }, + "body": { "displayName": "正文", "purpose": "默认正文。" }, + "label": { "displayName": "操作文字", "purpose": "按钮;主按钮单独增加字重。" }, + "field": { "displayName": "输入与导航", "purpose": "输入框、选择框与导航的字号和行高。" }, + "note": { "displayName": "辅助说明", "purpose": "范围、限制与状态补充。" }, + "link": { "displayName": "文本操作", "purpose": "带下划线的次要操作。" }, + "mono": { "displayName": "原始证据", "purpose": "可换行的原始证据。" } + }, + "shadows": [], + "motion": [ + { "name": "button-state", "value": "background-color .12s, border-color .12s", "purpose": "按钮背景和边框变化,沿用 CSS 默认 ease。" }, + { "name": "reduced-motion", "value": "transition: none !important; animation: none !important; scroll-behavior: auto !important", "purpose": "在 prefers-reduced-motion: reduce 下应用。" } + ], + "breakpoints": [ + { "name": "compact-desktop-max-width", "value": "1100px" } + ] + }, + "components": [ + { + "name": "主按钮", + "kind": "button", + "refersTo": "button-primary", + "description": "当前主要动作使用深色实心白字。", + "html": "", + "css": ".ds-primary{font:550 13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif;min-height:40px;padding:10px 22px;color:var(--paper,#fff);background:var(--ink,#202322);border:1px solid var(--ink,#202322);border-radius:var(--radius,7px);cursor:pointer;transition:background-color .12s,border-color .12s}.ds-primary:hover:not(:disabled){background:#394038;border-color:#394038}.ds-primary:focus-visible{outline:2px solid var(--ink,#202322);outline-offset:3px}.ds-primary:disabled{opacity:.45;cursor:default}@media(prefers-reduced-motion:reduce){.ds-primary{transition:none!important}}" + }, + { + "name": "普通按钮", + "kind": "button", + "refersTo": "button-secondary", + "description": "次要操作与工具调用使用白底细边。", + "html": "", + "css": ".ds-secondary{font:400 13px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif;min-height:36px;padding:8px 14px;color:var(--ink,#202322);background:var(--paper,#fff);border:1px solid #bdc5b9;border-radius:var(--radius,7px);cursor:pointer;transition:background-color .12s,border-color .12s}.ds-secondary:hover:not(:disabled){background:#f1f4ef;border-color:#919c89}.ds-secondary:active:not(:disabled){background:#e8ede4}.ds-secondary:focus-visible{outline:2px solid var(--ink,#202322);outline-offset:3px}.ds-secondary:disabled{opacity:.45;cursor:default}@media(prefers-reduced-motion:reduce){.ds-secondary{transition:none!important}}" + }, + { + "name": "文本操作", + "kind": "button", + "refersTo": "button-link", + "description": "设置、查看和选择等操作使用下划线。", + "html": "", + "css": ".ds-link{display:inline-flex;align-items:center;gap:8px;min-height:28px;padding:2px 0;font:400 12px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif;color:var(--ink,#202322);background:transparent;border:0;border-radius:2px;text-decoration:underline;text-underline-offset:3px;cursor:pointer}.ds-link:hover:not(:disabled){background:transparent;color:#000}.ds-link:focus-visible{outline:2px solid var(--ink,#202322);outline-offset:3px}.ds-link:disabled{opacity:.45;cursor:default}" + }, + { + "name": "输入框", + "kind": "input", + "refersTo": "input", + "description": "带可见标签的白底输入框。", + "html": "", + "css": ".ds-field{display:block;font:400 13px/1.65 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif;color:var(--ink,#202322)}.ds-field input{box-sizing:border-box;display:block;width:100%;min-height:40px;margin-top:7px;padding:9px 11px;font:400 13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif;background:var(--paper,#fff);color:var(--ink,#202322);border:1px solid #b9c1b5;border-radius:var(--radius,7px)}.ds-field input::placeholder{color:#6d7269;opacity:1}.ds-field input:focus{outline:2px solid var(--ink,#202322);outline-offset:1px;border-color:var(--ink,#202322)}.ds-field input:focus-visible{outline-offset:3px}" + }, + { + "name": "侧栏导航", + "kind": "nav", + "refersTo": "navigation", + "description": "文字配原创线条图标,当前页面有浅底色。", + "html": "", + "css": ".ds-nav{display:flex;flex-direction:column;gap:5px;padding:16px;background:var(--rail,#f5f6f4)}.ds-nav button{display:flex;align-items:center;gap:10px;min-height:42px;width:100%;padding:10px 12px;font:400 13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif;text-align:left;white-space:normal;border:0;border-radius:var(--radius,7px);color:var(--muted,#626763);background:transparent;cursor:pointer;transition:background-color .12s,border-color .12s}.ds-nav button[aria-current=page]{background:var(--selected,#e5e9e3);color:var(--ink,#202322);font-weight:600}.ds-nav button:hover:not(:disabled){background:#ecefea}.ds-nav button:focus-visible{outline:2px solid var(--ink,#202322);outline-offset:3px}.ds-nav svg{width:19px;height:19px;flex-shrink:0;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}@media(prefers-reduced-motion:reduce){.ds-nav button{transition:none!important}}" + }, + { + "name": "目标与守护摘要", + "kind": "card", + "refersTo": "summary-panel", + "description": "浅底细边的状态容器。", + "html": "

尚未启用

后台任务需先生成方案并明确启用。

", + "css": ".ds-panel{padding:18px 20px;background:#f7f9f5;color:var(--ink,#202322);border:1px solid var(--line,#dde1dc);border-radius:var(--radius,7px);font:400 14px/1.65 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif}.ds-panel h2{font-size:16px;font-weight:600;line-height:1.5;margin:0}.ds-panel p{font-size:12.5px;line-height:1.75;max-width:80ch;margin:6px 0;color:var(--muted,#626763)}" + }, + { + "name": "逐项检查", + "kind": "custom", + "refersTo": "inspection-row", + "description": "原生展开行保留选择、名称、状态与证据入口。", + "html": "
所选用途的域名解析所选用途的域名解析 · 目标验收尚未检测查看说明

查什么

按已确认的用途和连接方式,核对本次证据是否达到目标。

", + "css": ".ds-inspection{margin:0;padding:13px 12px;border-top:1px solid var(--line,#dde1dc);font:400 13px/1.65 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif;color:var(--ink,#202322)}.ds-inspection[open]{background:#fafbf9;padding-bottom:20px}.ds-inspection summary{display:flex;align-items:center;justify-content:space-between;gap:14px;line-height:1.8;cursor:pointer}.ds-inspection summary:focus-visible,.ds-inspection input:focus-visible{outline:2px solid var(--ink,#202322);outline-offset:3px}.ds-inspection-select{display:inline-flex;width:auto;margin:0 12px 0 0}.ds-inspection-select input{width:16px;height:16px;margin:0 8px 0 0;accent-color:var(--ink,#202322)}.ds-inspection-name{flex:1}.ds-inspection strong{font-size:13px;font-weight:550}.ds-inspection small{display:block;margin-top:3px;color:var(--muted,#626763);font-size:11px}.ds-inspection-state{font-size:12px;min-width:120px;text-align:right;white-space:nowrap}.ds-inspection-state small{text-decoration:underline;text-underline-offset:3px}.ds-inspection h3{font-size:14px;font-weight:550;margin:16px 0 4px 34px}.ds-inspection p{font-size:13px;line-height:1.75;max-width:80ch;margin:0 0 0 34px}" + }, + { + "name": "问题摘要", + "kind": "custom", + "refersTo": "problem-row", + "description": "名称、观察与手动处理状态排在同一行,原因按需展开。", + "html": "
配置读取权限操作系统拒绝读取现有配置。需要授权

展开后查看具体影响与处理步骤。

", + "css": ".ds-problem{margin:0;padding:18px 0;border-top:1px solid var(--line,#dde1dc);font:400 13px/1.65 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei UI',sans-serif;color:var(--ink,#202322)}.ds-problem[open]{padding-bottom:20px}.ds-problem summary{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;list-style:none;width:100%;line-height:1.8;cursor:pointer}.ds-problem summary::-webkit-details-marker{display:none}.ds-problem summary:focus-visible{outline:2px solid var(--ink,#202322);outline-offset:3px}.ds-problem-title{min-width:0;flex:1}.ds-problem-title strong{display:block;font-size:14px;font-weight:600}.ds-problem-title>span{display:block;color:var(--muted,#626763);font-size:12px;line-height:1.8;margin:5px 0 0;max-width:82ch;overflow-wrap:anywhere}.ds-problem-badge{font-size:12px;color:#a92828;white-space:nowrap;text-decoration:underline;text-underline-offset:4px;padding-top:1px}.ds-problem p{font-size:13px;line-height:1.75;max-width:80ch;margin:12px 0;color:var(--muted,#626763)}" + } + ], + "narrative": { + "northStar": "黑白极简桌面工具", + "overview": "面向 Windows 与 macOS 的日常操作界面。文字、细分隔线和少量浅色容器建立层级;检查、修复与守护沿同一操作流程展开,技术证据按需查看。", + "keyCharacteristics": [ + "固定窄侧栏与单一主内容区。", + "系统字体、可见操作文字和原创线条图标。", + "中性色为主,红色提示需要处理的故障或手动步骤。", + "桌面布局,不做手机适配。" + ], + "rules": [], + "dos": [ + "Do 保留可见文字、明确状态、键盘焦点与原生表单语义。", + "Do 把完整检查与技术证据放在可展开区域,保留访问路径。", + "Do 在 1024、1280、1440px 桌面宽度核对布局。" + ], + "donts": [ + "Don't 引入手机布局、营销首页、大量装饰卡片、渐变、发光或 emoji。", + "Don't 用颜色、图标或“通过”文案掩盖未测、未实现或权限不足。", + "Don't 把静态预览或视觉状态当作检测与修复已完成的证据。" + ] + } +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..c71770e --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,131 @@ +--- +name: EnvScope +description: 面向 Windows 与 macOS 的黑白极简环境检查桌面工具。 +colors: + paper: "#fff" + rail: "#f5f6f4" + ink: "#202322" + muted: "#626763" + line: "#dde1dc" + selected: "#e5e9e3" + panel: "#f7f9f5" + expanded: "#fafbf9" + primary-hover: "#394038" + danger: "#ad3030" + manual-danger: "#a92828" +typography: + headline: + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif' + fontSize: "24px" + fontWeight: 650 + lineHeight: 1.4 + letterSpacing: "-0.5px" + title: {fontSize: "16px", fontWeight: 600, lineHeight: 1.5} + body: + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif' + fontSize: "14px" + fontWeight: 400 + lineHeight: 1.65 + label: {fontSize: "13px", fontWeight: 400, lineHeight: 1.45} + field: {fontSize: "13px", fontWeight: 400, lineHeight: 1.5} + note: {fontSize: "12.5px", fontWeight: 400, lineHeight: 1.75} + link: {fontSize: "12px", fontWeight: 400, lineHeight: 1.55} + mono: {fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace", fontSize: "12px", lineHeight: 1.8} +rounded: + control: "7px" + link: "2px" +spacing: + xs: "4px" + sm: "8px" + md: "12px" + lg: "16px" + panel: "20px" + xl: "24px" + desktop-compact: "28px" + section: "32px" + desktop: "40px" +components: + button-primary: {backgroundColor: "{colors.ink}", textColor: "{colors.paper}", typography: "{typography.label}", rounded: "{rounded.control}", padding: "10px 22px"} + button-primary-hover: {backgroundColor: "{colors.primary-hover}", textColor: "{colors.paper}"} + button-secondary: {backgroundColor: "{colors.paper}", textColor: "{colors.ink}", typography: "{typography.label}", rounded: "{rounded.control}", padding: "8px 14px"} + button-link: {backgroundColor: "transparent", textColor: "{colors.ink}", typography: "{typography.link}", rounded: "{rounded.link}", padding: "2px 0"} + input: {backgroundColor: "{colors.paper}", textColor: "{colors.ink}", typography: "{typography.field}", rounded: "{rounded.control}", padding: "9px 11px"} + navigation: {backgroundColor: "transparent", textColor: "{colors.muted}", typography: "{typography.field}", rounded: "{rounded.control}", padding: "10px 12px"} + navigation-current: {backgroundColor: "{colors.selected}", textColor: "{colors.ink}"} + summary-panel: {backgroundColor: "{colors.panel}", textColor: "{colors.ink}", rounded: "{rounded.control}", padding: "18px 20px"} + inspection-row: {backgroundColor: "transparent", textColor: "{colors.ink}", padding: "13px 12px"} + inspection-row-open: {backgroundColor: "{colors.expanded}", padding: "13px 12px 20px"} + problem-row: {backgroundColor: "transparent", textColor: "{colors.ink}", padding: "18px 0"} +--- + +# Design System: EnvScope + +## Overview + +**Creative North Star: "黑白极简桌面工具"** + +面向 Windows 与 macOS 的日常操作界面。文字、细分隔线和少量浅色容器建立层级;检查、修复与守护沿同一操作流程展开,技术证据按需查看。 + +**Key Characteristics:** + +- 固定窄侧栏与单一主内容区。 +- 系统字体、可见操作文字和原创线条图标。 +- 中性色为主,红色提示需要处理的故障或手动步骤。 +- 桌面布局,不做手机适配。 + +来源:`envscope/web/minimal.css`,随后由 `workspace.css` 覆盖;页面整理见 `workspace_ui.mjs`,图标见 `icons.mjs`。记录以当前实际级联为准。 + +## Colors + +### Primary + +`ink` 用于正文、实心主按钮和焦点;主按钮反白。`danger` 用于手动说明、检查错误和检查状态。当前手动问题徽标及检查行左边线仍使用 `manual-danger`,两者均为现有实现。 + +### Neutral + +`paper` 是主画布,`rail` 是侧栏与筛选区,`panel` 用于目标与守护摘要,`expanded` 用于展开行。`line` 分隔内容,`selected` 标识当前导航,`muted` 承载补充说明。状态始终保留文字,不仅依靠颜色。 + +## Typography + +平台系统无衬线字体覆盖中文与英文;原始证据使用等宽字体。页面标题用 `headline`,常规分节用 `title`,正文用 `body`,辅助说明用 `note`。较小的状态与版本文字使用(11–12px),结果摘要标题使用(18px)。主按钮字重(550),当前导航字重(600);其余角色沿用系统字体。说明通常限制为(78–80ch),表格与运行状态使用等宽数字。 + +## Layout + +左侧栏固定,宽(204px);主区留白为(26px 40px 48px),内容最大宽(1024px)。主要入口为检测与修复、持续守护、账号资料;其余功能在侧栏内展开,帮助与结束本地服务位于底部。 + +窗口不超过(1100px)时,侧栏收至(184px),主区留白变为(24px 28px 40px)。最小页面宽(960px),不设置手机断点;视觉检查覆盖(1024、1280、1440px)。常规表单为两列,检查筛选区为三列,操作行可换行。 + +主流程展示目标摘要、问题、修复方案与守护区。当前流程进度条隐藏;详细检查、证据与高级维护使用原生展开控件。间距采用 frontmatter 中的常用步长,局部内边距以组件记录为准。 + +## Elevation & Depth + +不使用阴影。浅色填充、细边线和留白区分层级;展开行使用浅底。固定侧栏和检查工具栏通过位置保持可用,不添加浮起效果。 + +## Shapes + +控件与摘要容器使用 `control` 圆角,问题与检查列表保持直边分隔。边线通常为(1px);手动检查行有(3px)红色左边线。图标为(24)单位网格、(1.7px)圆头描边,常规显示(19px),底部操作显示(16px),始终配可见文字。应用标记使用独立的(64)单位取景框图形。 + +## Components + +- **按钮:** 主按钮深底白字,普通按钮白底细边,文本操作带下划线。普通按钮最小高(36px),主按钮最小高(40px)。一键检测保留扫描图形、最小宽(140px)及具体忙碌文字;禁用状态降低透明度,防止重复操作。 +- **输入框:** 白底细边,文字框与选择框最小高(40px),复选框保留原生语义。键盘焦点为正文色(2px)外框,偏移(3px);输入框聚焦时边线加深。 +- **导航:** 图标与名称水平排列,行最小高(42px),当前页面浅底加粗。切页保留滚动位置并聚焦标题;“更多功能”可用 Escape 收起。 +- **摘要容器:** 目标与守护摘要使用浅底细边,信息输出框为白底;工具分区使用横线分隔。 +- **检查列表:** 左侧选择框,中间名称与说明,右侧状态与展开文字。展开显示依据和操作;刷新保留选择、展开状态与焦点。批量工具栏保持可见。 +- **问题与方案:** 摘要列出名称、观察与处理状态,原因按需展开。修复先展示影响与改动,再确认;结果与证据归入同一流程。 + +按钮仅对背景色和边框色做(120ms)过渡,不使用进入动画;减少动态效果设置关闭过渡与动画。 + +## Do's and Don'ts + +### Do + +- Do 保留可见文字、明确状态、键盘焦点与原生表单语义。 +- Do 把完整检查与技术证据放在可展开区域,保留访问路径。 +- Do 在 1024、1280、1440px 桌面宽度核对布局。 + +### Don't + +- Don't 引入手机布局、营销首页、大量装饰卡片、渐变、发光或 emoji。 +- Don't 用颜色、图标或“通过”文案掩盖未测、未实现或权限不足。 +- Don't 把静态预览或视觉状态当作检测与修复已完成的证据。 diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..ed10e55 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,14 @@ +# EnvScope + +EnvScope 是面向 Windows 与 macOS 的开源 Claude 环境检查工具。用户通常不了解检测术语, +需要确认目标地区与时区,完成一轮检查,看到具体问题、可审核的修复方案及长期守护方案。 + +已确认约束:极简界面;不做手机端;保留全部逐项检查、全选、账号资料交换、工具、历史和 +设置;首次使用提供教学;真实修改先展示影响并确认;普通检查不要求永久管理员权限。 +采用未正式签名的开源构建,不以获取证书作为交付前提。 + +界面不能用视觉效果掩盖未测、未实现或权限不足。时区、出口和语言的一致性不等于官方 +地区资格或 Claude 账号可用。Scrapling 用于研究收集,不是产品运行依赖。 + +设计场景是桌面日常操作:清晰文字、少量常用入口、可展开的细节和明确的操作进度。 +不采用营销首页、风险评分、大量装饰卡片、渐变、发光、emoji 或夸张标题。 diff --git a/README.md b/README.md index 013062b..4fa7f6c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ 面向 Windows 和 macOS 的极简桌面环境工具。以 [claude-switcher](https://github.com/YOURSJEYO/claude-switcher) 为功能底座,保留原版维护能力,增加独立检测、事务式账号管理、代理诊断和历史对比。 -当前源码 **0.24.1 Alpha** 按“地区与时区 → 一键检测 → 问题和修复 → 持久化守护”运行。复检后自动更新修复方案,保留重新生成入口和上次执行结果;方案过期、权限拒绝或检测失败会说明原因与下一步。时区、地区格式和 Code 配置合并审核,确认后修复并复检;守护状态显示具体恢复结果。地区预设覆盖 **247 个国家和地区、417 个城市/时区**。账号、全部检查、工具和历史统一放在“更多功能”。参见 [操作流程](docs/SIMPLE_WORKFLOW.md) 和 [完整功能对照](docs/FULL_PARITY_AUDIT.md)。公开安装包以 Releases 为准。 +当前源码 **0.25.0 Alpha** 按“地区与时区 → 一键检测 → 问题和修复 → 持久化守护”运行。侧栏直接进入检测、守护和账号,其余检查、工具和历史集中在“更多功能”;切换页面保留位置,筛选保留展开说明。地区预设覆盖 **247 个国家和地区、417 个城市/时区**。参见 [操作流程](docs/SIMPLE_WORKFLOW.md) 和 [完整功能对照](docs/FULL_PARITY_AUDIT.md)。公开安装包以 Releases 为准。 + +**0.25.0 新增**按失败项目引导系统授权,返回后复检原选择;明确区分权限拒绝、组织策略、超时和本版未支持。Windows 增加受保护的系统时区守护,与 macOS 一样先审核授权、每 60 秒检查、失败留痕并可恢复。异常通知去重,重开软件后仍可查看未解决事项。应用、托盘和导航采用统一原创图标。项目按纯开源方式发布,商业签名和 Apple 公证可选;包身份与完整性校验不等于发布者签名。参见 [权限验收](docs/PERMISSION_ACCEPTANCE.md) 和 [开源构建说明](docs/OPEN_SOURCE_BUILDS.md)。 **0.23.0 已加入**可恢复的旧软件清理、临时浏览器独立清理进程与重启恢复,以及 48 小时不同出口集合统计。清理支持单项 / 全选,先预览、再确认,保留账号与凭据。参见 [旧软件清理](docs/LEGACY_SOFTWARE.md) 和 [临时浏览器](docs/BROWSER_SESSIONS.md)。 @@ -45,7 +47,7 @@ Windows 桌面包解压后双击 `EnvScope.exe`。macOS 桌面包的入口为 `E 1. 确认实际使用地区、节点地区和城市。出口 IP 可建议节点地区与时区;不会代填实际所在地。需要时展开用途与连接选项。 2. 点“确认并一键检测”。首页列出不合格项目、缺少的必需证据和下一步;展开对应问题查看原因与手动步骤。 3. 核对修复方案,点“确认修复并复检”。写入前保存恢复记录;代理连接未验证时不会写入。需要先启动客户端时,完成并复检后再审核后续改动。 -4. 在“持久化守护”勾选需保持的配置,预览并确认启用;同页可停用。账号、选择检查 / 全选、备份与高级修复从“更多功能”进入。 +4. 在“持续守护”勾选需保持的配置,预览并确认启用;同页可停用。系统守护与高级修复按需展开;选择检查 / 全选和备份从“更多功能”进入,账号资料可从侧栏直接进入。 复检区分已消除、仍存在和仍缺少证据的问题。“详细记录与使用帮助”保留打开 / 正常重开 Claude、实际试用指引和技术证据。Code 配置修改后需要重开会话;实际账号使用仍须在自己的 Claude 中确认。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3cc4325..ccc20d1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,11 +14,11 @@ ## 使用流程 -Windows 双击 `EnvScope.exe`,macOS 双击 `EnvScope.app`。本机服务启动并打开默认浏览器。用户点击“一键检测”,页面自动合并系统与当前浏览器观测,可导出一份隐藏地址的结果。 +Windows 双击 `EnvScope.exe`,macOS 双击 `EnvScope.app`。桌面包启动独立窗口及本机服务;源码网页入口才打开浏览器。确认目标后点击“一键检测”,自动合并系统与当前浏览器观测,可导出隐藏地址的结果。 -源码入口为 `start-windows.cmd` / `start-macos.command`,需要 Python 3.10+。桌面打包版本自带 Python 运行时,不包含 Scrapling。当前 Windows 打包已在本机验证;macOS 的采集代码及构建流程已准备,真机验证另行记录。 +源码入口为 `start-windows.cmd` / `start-macos.command`,需要 Python 3.10+。桌面包自带 Python 运行时,不包含 Scrapling。Windows、macOS ARM64 与 Intel 的冻结包由原生 CI 验证;真实用户授权交互另行记录。 -不提供手机端适配。界面使用桌面单列布局、黑白文字、细分隔线和折叠详情。没有分数、卡片、装饰图标或大幅口号。 +不提供手机端适配。界面采用极简桌面布局、清晰的层级与折叠详情;图标服务于导航和状态,不引入风险评分或装饰性大幅口号。 ## 数据流 @@ -95,4 +95,23 @@ STUN 验证消息类型、magic cookie、transaction ID、消息长度、属性 ## 仍待完成的边界 -没有权威 DNS 服务、目标进程与套接字关联、抓包证据或原生系统防火墙保护。Windows 的原生加密快照、临时浏览器、系统地区变更以及整个产品的签名更新尚未实现。没有位置伪装或账号风险评分。完整能力与平台差异见 [功能对照](FEATURE_PARITY.md)。 +没有权威 DNS 出口测量、完整目标进程与套接字关联、抓包证据或原生系统防火墙保护。Windows 已具备加密快照、临时浏览器、地区变更与受保护的时区任务;这些能力不能替代服务端账号验证。当前发布采用未正式签名的开源构建,参见 [构建身份与更新](OPEN_SOURCE_BUILDS.md)。没有位置伪装或账号风险评分。完整能力与平台差异见 [功能对照](FEATURE_PARITY.md)。 + +## 权限、后台能力与结果证据 + +`observation_errors.py` 保留每个子项的拒绝、策略限制、超时、工具缺失或解析错误。 +读取失败会传到依赖检查和汇总;部分采集不会变成全项通过。`permissions.py` 根据实际 +拒绝和当前使用目标返回用途、作用范围、相关检查与系统入口。`permission_flow.mjs` +仅在返回设置时复检一次;目标或选择改变则使等待中的续检失效。 + +`guard_capabilities.py` 只读检查当前令牌能力。Windows 用户具备时区权限时继续使用 +原用户任务;缺权时前台申请受保护的 SYSTEM 任务。`timezone_windows.py` 和 macOS +`timezone_service.py` 均提供预览、确认、独立读回、恢复和撤销。前台授权与周期心跳分别 +记录,后台不会弹授权窗口。`guard_notifications.py` 对需要处理的状态去重,正常巡检和 +恢复只留日志。实施与验收范围见 [权限升级验收表](PERMISSION_ACCEPTANCE.md)。 + +Windows 安装器与原生守护共用独占事务锁。安装器先写受保护的事务记录, +完成后原子发布该记录的摘要;守护只接受相符的提交摘要。初次设置时区由获授权的 +前台事务写入并读回,周期任务没有绕过提交检查的入口。升级中断导致程序与基线 +暂时不匹配时,恢复入口仍读取受保护的记录和备份摘要,普通巡检继续拒绝该状态。 +两个平台的状态页与 Watchdog 共用心跳身份、策略、时效检查。 diff --git a/docs/DESKTOP_ACCEPTANCE.md b/docs/DESKTOP_ACCEPTANCE.md index 036f0cc..5107d1c 100644 --- a/docs/DESKTOP_ACCEPTANCE.md +++ b/docs/DESKTOP_ACCEPTANCE.md @@ -1,4 +1,13 @@ -# 0.8 独立软件与功能回归 +# 独立软件与功能回归 + +## 0.25 权限与桌面刷新 + +- Python 回归 720 项,5 项按平台跳过;JavaScript 19 项及全部模块语法检查通过。 +- Windows/macOS 离线场景各覆盖 1024、1280、1440 宽度的 8 个页面,检查键盘菜单、滚动位置、筛选展开状态、单次检测提交和无横向溢出。 +- 首次教学与 IP 读取、原生设置刷新与保存、修复中的复检按钮均加入竞争时序回归;仅只读 IP 查询可在服务短暂忙碌时有限重试。 +- 浏览器回归覆盖权限返回、原选择复检、取消、仍拒绝、目标变更,以及修复预览、过期、部分失败、恢复与守护。Windows 冻结包核对应用图标、版本资源和更新后的原生启动。 +- 专用 CI 使用隔离时钟完成 Windows SYSTEM / macOS root 时区服务生命周期与两轮自动巡检,不改测试机器真实时区。最终标签的构建结果为分发依据。 +- 实际人工 UAC/TCC 允许、拒绝、撤权、重启及休眠仍需专用设备验收,未用上述夹具代替。参见 [权限证据表](PERMISSION_ACCEPTANCE.md)。 产品流程:首次教学 → 一键检测 → 问题与权限分类 → 合并修复方案 → 确认写入 → 自动复检 → 实际 Claude 使用 → 可选日常维护。普通检查无需先配置比较目标或维护核心。 @@ -12,13 +21,17 @@ - Windows 系统信息逐项采集;某一字段失败不丢弃其余字段。 - 使用条件结论与严格隐私判定、账号启动门槛分开。后两者未放宽; DNS 权威出口、进程流量关联未实现,IPv6/UDP 无响应不记为通过。 -- 只有 PermissionError 才给权限原因。一般 I/O 失败不会伪装成权限不足。 +- 仅真实拒绝或已识别的系统拒绝代码给出权限原因;组织策略限制、超时、工具缺失和一般 I/O 失败分别展示。 - 独立窗口使用 pywebview,托盘使用 pystray。默认关闭时驻留;托盘未就绪则保留 正常退出方式。执行中不退出;重复启动唤回已有窗口。 - 更新只信任指定仓库、HTTPS、目标架构与合法版本号。下载同时核对 GitHub 摘要、 SHA256SUMS、大小及归档路径。安装在旁边的版本目录;校验失败保留当前版本。 原始下载目录始终保留,应用内回退针对已保存的前一更新版本。 +本轮权限与后台升级的当前证据见 [权限验收表](PERMISSION_ACCEPTANCE.md)。更新还核对 +`io.qudadd.envscope` 应用标识与包内版本;当前构建未进行正式发布者签名或 Apple 公证, +详见 [开源构建](OPEN_SOURCE_BUILDS.md)。 + ## 可重复验收 | 范围 | 验收入口与证据 | 边界 | diff --git a/docs/OPEN_SOURCE_BUILDS.md b/docs/OPEN_SOURCE_BUILDS.md new file mode 100644 index 0000000..295502c --- /dev/null +++ b/docs/OPEN_SOURCE_BUILDS.md @@ -0,0 +1,26 @@ +# 开源桌面构建与更新身份 + +本项目发布源码及 Windows/macOS 独立软件。当前无需商业签名证书:Windows 构建未进行 +Authenticode 发布者签名;macOS 使用本地 ad-hoc 签名,不具备 Developer ID 身份,也未公证。 +SHA-256 和应用标识用于检查下载与版本一致性,不能证明发布者签名。 + +应用固定使用 `io.qudadd.envscope`。Windows EXE 的内部名称、文件版本和产品版本, +macOS 的 `CFBundleIdentifier`、`CFBundleShortVersionString`、`CFBundleVersion`,以及 +包内 `build-identity.json` 都由同一源码版本生成。升级保持这个应用标识;用户资料沿用原路径。 +首次从旧版 `EnvScope` macOS 标识迁移时,系统可能要求重新核对相关隐私权限;打开设置本身 +不代表授权成功,回到软件后重新检测实际能力。 + +更新流程依次核对固定 GitHub 仓库、系统架构、版本、下载大小、GitHub 摘要、发布校验表、 +安全解包路径和包内应用身份。新版本安装在旁边的版本目录,保留旧版与资料。主程序或身份 +记录在安装后改变时停止启动该更新。现有旧版回退记录仍可用,不会因新增身份文件要求丢失回退。 + +目前不接受包内自行声称“发布者已验证”或“已公证”的构建。若今后启用正式签名,需要单独 +提交并审查发布流程:证书通过仓库秘密配置、固定签发者/Team ID、公证结果读回,并在更新端 +校验真实操作系统签名。只添加环境变量或 JSON 标志不能启用此模式。不要将密码、证书私钥 +或 Apple 账号凭据提交到仓库。 + +可重复构建入口:`python -m scripts.build_desktop`。最终包运行 +`python -m scripts.packaged_smoke <主程序路径>`,更新路径运行 +`python -m scripts.update_package_smoke`。测试使用临时资料,不登录 Claude 或改变真实系统配置。 + +构建依据:[PyInstaller 应用标识与版本资源](https://www.pyinstaller.org/en/stable/usage.html)。 diff --git a/docs/PERMISSION_ACCEPTANCE.md b/docs/PERMISSION_ACCEPTANCE.md new file mode 100644 index 0000000..015fad4 --- /dev/null +++ b/docs/PERMISSION_ACCEPTANCE.md @@ -0,0 +1,31 @@ +# 权限与后台升级验收 + +本表区分实现、隔离测试和真实用户设备观察。测试中的允许/取消/拒绝响应,不代表已由 +真实用户点击 UAC/TCC。开发和回归测试不修改用户的实际时区、网络、应用或账号。 + +| 审查项 | 已实施 | 验证入口 | 设备边界 | +|---|---|---|---| +| 子项失败但汇总通过 | 结构化子项错误传递到汇总及依赖项 | `test_inventory_permissions` | 模拟 Windows/macOS 拒绝、超时、部分结果 | +| 授权用途及续检 | 按检查和目标给入口,返回后一次复检,目标变更作废旧续检 | `test_permissions`、`permission_flow.test.mjs`、`permission_flow_smoke` | 系统设置与授权响应使用夹具 | +| 后台写入权限 | 原用户能力预检;缺权时前台批准原生系统时区任务;每轮重验 | `test_watchdog`、`test_windows_timezone_service`、`test_timezone_integration` | 原生调度的真实周期另列于发布 CI | +| 取消和半完成操作 | 区分取消、策略拒绝、执行失败和超时;保存受保护事务供恢复 | `test_consent`、时区/网络/迁移事务测试 | 不由退出码假设没有发生写入 | +| 应用身份与更新 | 固定应用 ID、原生版本资源、更新包身份和摘要核对 | `test_app_identity`、`test_desktop_runtime`、冻结包与更新烟测 | Windows 未签名;macOS ad-hoc;正式签名按开源交付范围延期 | +| 权限变更及异常提醒 | 停用、其他用户归属、心跳过期、文件篡改、反复修复暂停、去重通知 | `test_guard_notifications`、原生服务与后台测试 | 真实用户授权撤销、重启及睡眠唤醒尚待专用设备验收 | + +Windows 原生验收 `scripts.windows_timezone_acceptance` 只在明确启用的 CI 管理员会话中 +运行,使用唯一任务名与文件模拟时钟。它验证 SYSTEM 自动周期、可写 ACL 拒绝、移除与 +恢复、撤销和事务中断恢复;执行前后比较真实 `tzutil /g`。macOS 的对应入口为 +`scripts.timezone_acceptance`,使用唯一 fixture 路径及真实 LaunchDaemon,并核对 +`/etc/localtime` 未改变。fixture 辅助程序不进入发布包。 + +当前本地完整回归:720 项 Python 测试通过(5 项平台跳过),19 项 JavaScript 测试通过; +真实 Windows 冻结包和离线更新包的窗口、托盘、隐藏、恢复、退出与版本身份检查通过。 +跨平台原生验收以本分支和最终发布标签的 [desktop packages 工作流](https://github.com/qudadd/envscope/actions/workflows/desktop.yml) +结果与对应产物为准;未成功的运行不计为通过。 + +后续专用设备矩阵仍待执行:普通用户首次允许/取消、组织策略拒绝、运行中撤权、升级后 +再次打开、系统重启、睡眠唤醒。当前开源 Alpha 可继续交付,但不得将这些待验证场景写成 +已通过。正式签名与公证不是当前交付前提,也不替代任何 OS 权限检查。 + +权限无法补齐未实现的检测:权威 DNS 出口、完整进程流量关联、Claude 登录、账号额度与 +实际回复各有自己的证据来源;超时、未实现和缺少认证不应一概显示为“申请权限即可修复”。 diff --git a/docs/SIMPLE_WORKFLOW.md b/docs/SIMPLE_WORKFLOW.md index 48a3997..afbe6d8 100644 --- a/docs/SIMPLE_WORKFLOW.md +++ b/docs/SIMPLE_WORKFLOW.md @@ -5,9 +5,9 @@ 1. **地区与时区**:填写实际所在地;读取当前出口 IP,建议节点地区及对应城市。选择城市会匹配 Windows / macOS 时区、地区格式和语言目标。手动选择和已保存目标不会被迟到的查询结果覆盖。用途与连接方式按需展开。 2. **一键检测**:完成目标确认后开始。结果说明必需项通过、不合格和待完成的数量;所有问题标题、实际观测与处理方式直接可见。需要权限、缺少证据和本版不支持的检查分别说明,不计为通过。 3. **修复方案**:先列出具体字段、原值与目标或可执行操作。时区、地区格式与 Code 配置合并审核,确认后才执行,随后自动复检。复检使旧方案失效,再按当前问题生成新方案;重新生成入口始终保留。方案生成失败可重试,检测失败时说明需要先重新检测。上次执行结果、失败原因、备份和恢复入口独立保留;部分失败不会重复自动执行,需核对恢复记录。需要先启动客户端的操作仍分阶段复检;无法自动处理的项目标红并给出步骤。 -4. **持久化守护**:选择周期以及需要保持的时区、地区格式和代理;额外客户端与 Code 选项折叠。预览具体基线并确认启用,同页可停用。后台仅执行已批准的范围并保存结果,未获得权限的修改保留原因。首页显示最近完成时间和各项基线的实际恢复结果;失败、过期或暂停以红色提示,完整日志在“更多功能 → 守护与高级修复”。 +4. **持久化守护**:选择周期以及需要保持的时区、地区格式和代理;额外客户端与 Code 选项折叠。预览具体基线并确认启用,同页可停用。后台仅执行已批准的范围并保存结果,未获得权限的修改保留原因。首页显示最近完成时间和各项基线的实际恢复结果;失败、过期或暂停以红色提示,完整日志在侧栏“持续守护”。 -顶部“更多功能”提供全部检查(选择 / 全选 / 批量处理)、账号资料、工具与备份、守护与高级修复、历史、设置与更新、环境全览。详细测量、帮助和 Claude 启动入口放在首页末尾,不妨碍主流程。 +侧栏直接提供检测、持续守护和账号资料。“更多功能”提供全部检查(选择 / 全选 / 批量处理)、工具与备份、历史、设置与更新、环境全览。维护页的高级系统功能集中在可展开区域。详细测量、帮助和 Claude 启动入口放在首页末尾。 ## 地区覆盖 diff --git a/docs/TIMEZONE_SERVICE.md b/docs/TIMEZONE_SERVICE.md index b97ebe9..557cc41 100644 --- a/docs/TIMEZONE_SERVICE.md +++ b/docs/TIMEZONE_SERVICE.md @@ -1,16 +1,23 @@ -# macOS 系统时区守护(0.16 Alpha) +# Windows / macOS 系统时区守护 先填写实际使用地区和期望城市,完成一次当前目标的检测。在“保持本次设置”勾选 时区守护并预览,所需系统授权会合并在同一份方案中。也可在“持续维护”的系统 时区守护入口独立安装或更新。普通检测不会安装服务,应用不接收或保存管理员密码。 -确认后 macOS 申请管理员授权,此改动影响整台 Mac 的所有用户。仅完整 macOS -独立桌面包随附已编译的辅助程序;源码安装缺少该程序时会给出获取完整包的提示。 -Windows 继续使用用户任务恢复批准值,权限不足时标红并给出系统设置入口。 +确认后按需申请系统授权,此改动影响整台电脑。完整独立桌面包随附编译好的辅助程序; +源码安装缺少该程序时会给出获取完整包的提示。Windows 先检查当前用户令牌,具备时区 +权限时使用原用户任务;缺少权限时将系统任务授权纳入同一份守护方案。 + +Windows 系统任务 `EnvScope-TimezoneGuard` 使用 SYSTEM 身份,每 60 秒运行固定的原生 +`timezone-helper.exe --once`。固定目录为 `%ProgramData%/EnvScope/TimezoneGuard`, +只允许 SYSTEM 与管理员写入;普通用户可读状态及任务定义。前台安装脚本是可读的固定源码, +提权入口核对一次内存快照的摘要后执行;不会降低执行策略,也不会把可写脚本交给后台任务。 +辅助程序执行前核对路径、所有者、ACL 与自身摘要,仅在写时区期间启用所需令牌权限。 +事务日志为 `lifecycle-YYYY-MM-DD.jsonl`,周期事件为 `events-YYYY-MM-DD.jsonl`。 ## 自动处理和撤销 -- 原生 LaunchDaemon 每 60 秒读回时区;有漂移时记录原值和目标,调用系统时区接口, +- 原生 Windows 任务 / macOS LaunchDaemon 每 60 秒读回时区;有漂移时记录原值和目标,调用系统时区接口, 再独立读回。启动安装时的一次读回不算周期巡检成功,界面等待新心跳。 - 尝试至少相隔 5 分钟,24 小时最多 3 次;调用超过 20 秒会停止等待并记录。 未完成写入、反复失败或系统时间异常不会计为守护正常。 @@ -25,6 +32,10 @@ Windows 继续使用用户任务恢复批准值,权限不足时标红并给出 ## 权限和文件 +以下固定服务路径说明适用于 macOS。当前最低版本保留 macOS 12,因此仍使用受保护 +LaunchDaemon;[SMAppService](https://developer.apple.com/documentation/servicemanagement/smappservice) +适用于 macOS 13 及以上,后续迁移需一并验证批准状态和旧服务移除,不能仅替换注册调用。 + 辅助程序是独立 C 程序,只链接系统库和 CoreFoundation;不以 root 运行 Python、 PyInstaller 入口、用户可写脚本或上游代码。打包时以压缩数据载荷保留辅助程序的原始 字节,避免打包器重签名改变摘要;先在普通用户的私有临时缓存解包并校验。每次前台 diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index a021d9c..f62c48e 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -1,8 +1,8 @@ # 桌面界面 -0.24 将首页收敛为一条流程:确认地区与时区、检测、查看问题与修复方案、启用持久化守护。面向 Windows 和 macOS,暂不适配手机端。 +0.25 保留一条主流程:确认地区与时区、检测、查看问题与修复方案、启用持久化守护。面向 Windows 和 macOS,暂不适配手机端。 -顶部仅保留 EnvScope、版本、检测与修复、“更多功能”。原有账号、全部检查、工具、历史和高级维护完整保留在菜单中。首页不显示空的结果表;首次使用以简短行内提示引导填写目标,完整教学可从页脚重看。 +固定窄侧栏直接进入检测与修复、持续守护、账号资料;全部检查、工具、历史、设置和覆盖说明从“更多功能”进入。维护页优先展示状态和方案,系统服务及高级修复按需展开。首页不显示空的结果表;首次使用以简短行内提示引导填写目标,完整教学可从侧栏重看。 地区设置默认显示实际使用地区、节点地区、城市 / 时区所在地和目标时区。选择城市自动匹配地区格式与语言;用途、连接方式和其他要求折叠。节点地区依据出口建议,用户手动选择优先,实际所在地独立填写。 @@ -11,7 +11,7 @@ 修复区先列具体改动,只保留一个确认修复并复检按钮,并保留“重新生成处理方案”的次要入口。复检清除旧方案、刷新剩余问题;上次执行结果独立保留。没有自动修改或没有取得本轮报告时,入口置灰并说明原因。持久化守护紧随其后:选择间隔与需保持的配置,预览、确认、启停都在同页。后台权限或修复失败必须显示原因,不能仅凭写入成功就显示环境合格。 ```text -EnvScope 版本 检测与修复 更多功能 +侧栏:EnvScope / 检测与修复 / 持续守护 / 账号资料 / 更多功能 Claude 环境检查 一键检测 地区与时区 调整目标 @@ -28,7 +28,9 @@ Claude 环境检查 一键检测 详细记录与使用帮助(按需展开) ``` -视觉使用白、灰、黑及手动处理的红色文字。系统字体、细分隔线、可见键盘焦点;黑底仅用于主要操作。无渐变、阴影、装饰图标、营销标题或状态卡片。正文居中、最大 1040 像素,桌面内边距 40 像素。 +视觉使用白、灰、黑及手动处理的红色文字。系统字体、细分隔线、可见键盘焦点;黑底用于主要操作。应用和托盘标记及导航线条图标均为项目原创,有对应文字标签。正文区最大 1024 像素,桌面内边距 28–40 像素。具体色彩、尺寸和交互约定见 [设计系统](../DESIGN.md)。 + +切换页面保留滚动位置,检查列表筛选保留展开状态和选择。检测重复提交会被阻止,修复复检期间保持操作锁。原生状态读取期间暂停设置输入,避免后台刷新覆盖刚修改的勾选。IP 读取遇到短暂忙碌时有限重试,配置写入不自动重试。 主流程和菜单在 1024、1280、1440 宽度通过隔离浏览器测试与截图检查;代理、时区和守护均使用一次性模拟数据。截图不能作为真实 Claude 登录或网络可用的证据。详见 [流程与地区覆盖](SIMPLE_WORKFLOW.md)。 diff --git a/docs/images/desktop.png b/docs/images/desktop.png index 5c92f13..7132984 100644 Binary files a/docs/images/desktop.png and b/docs/images/desktop.png differ diff --git a/docs/releases/0.25.0-alpha.1.md b/docs/releases/0.25.0-alpha.1.md new file mode 100644 index 0000000..b71288a --- /dev/null +++ b/docs/releases/0.25.0-alpha.1.md @@ -0,0 +1,17 @@ +# EnvScope 0.25.0 Alpha + +本版完善读取权限与后台守护,并简化 Windows / macOS 桌面操作。 + +- 读取失败分别显示权限拒绝、组织策略、超时、工具缺失或本版未支持。根据当前失败项目提供授权入口,返回后复检原选择;取消与目标改变不会沿用旧证据。 +- Windows 增加受保护的系统时区守护,与 macOS 服务一样按批准基线运行、读回、限频、记录和恢复。后台权限不足不会声称已经保护,也不会反复弹出授权框。 +- 守护异常通知去重,重启应用后仍可查看待处理事项。 +- Windows 守护升级中断时保留恢复入口;未完成事务阻止后台写入,恢复后才重新巡检。心跳核对版本、策略和目标,避免旧状态误报正常。 +- 首页集中显示目标、检测、问题与修复方案。侧栏直接进入守护和账号,高级系统操作按需展开;保留选择检查、全选、备份、恢复和工具。 +- 更新应用、托盘和导航图标;保留页面滚动与展开说明,防止检测重复提交。设置刷新和保存、教学与 IP 读取之间的时序冲突已修正。 +- 安装包使用固定应用标识与版本资源;更新校验包身份、摘要和发布版本。 + +这是纯开源 Alpha 版本。Windows 包未作发布者签名;macOS 使用构建所需的临时签名,未作 Developer ID 签名或 Apple 公证。完整性与身份一致性校验不等于发布者认证。 + +自动测试使用隔离配置和模拟网络;原生服务测试使用独立任务和时钟。人工 UAC/TCC 授权、撤权、长期休眠和真实 Claude 登录仍需专用设备或用户环境验证,未计入自动测试通过。具体范围见 [权限验收](../PERMISSION_ACCEPTANCE.md) 与 [桌面验收](../DESKTOP_ACCEPTANCE.md)。 + +请下载对应系统的 ZIP 并完整解压:Windows x64、macOS Apple Silicon 或 macOS Intel。旧版可保留用于回退;如已配置后台维护,移动应用目录后需重新核对任务入口。 diff --git a/docs/superpowers/plans/2026-10-30-permissions-and-ui.md b/docs/superpowers/plans/2026-10-30-permissions-and-ui.md new file mode 100644 index 0000000..91acfbf --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-permissions-and-ui.md @@ -0,0 +1,101 @@ +# Permissions and desktop refresh Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the permission/usability optimizations and their acceptance gates, then deliver the complete minimal desktop UI/icon/interaction upgrade. + +**Architecture:** Extend existing collectors, Operations, webview and native helper transactions. Keep ordinary tasks in the user's session, privileged operations narrowly scoped and consent results independently verified. + +**Tech Stack:** Python/unittest, JavaScript/node tests, Playwright, native C/OS helpers, PyInstaller, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md` + +## Global Constraints + +- Windows/macOS; monochrome/minimal; one main test button; no mobile work. +- No real system changes during development/testing; explicit reviewed boolean confirmation for product writes. +- Preserve vendor, credentials, account recovery, report freshness and unknown-result boundaries. +- User clarified on 2026-10-30: deliver as an open-source project without signing credentials. Formal publisher signatures and Apple notarization are optional, not delivery gates. Keep unsigned build identity and verification honest. Record real authorization device evidence separately from automated fixture evidence. +- Complete optimization acceptance before the overall visual refresh. + +## Review Focus + +- Permission revoked while the app is in the background: preserve the failed capability and do not repeat consent prompts. +- Target/check selection changes while authorization is pending: discard stale continuation and require a current report. +- User cancels after a partial native transaction: reconcile actual receipts, do not claim nothing changed. +- Service binary/config tampered with or owned by a different user: refuse execution and preserve recovery material. +- App upgrade or sleep/resume changes service state: verify identity/heartbeat before claiming protection. + +### Task 1: Truthful collection evidence + +**Files:** Create `envscope/observation_errors.py`, `tests/test_inventory_permissions.py`; modify `inventory.py`, `network_facts.py`, `probes.py`, `baseline.py`, `target_environment.py` and their focused tests. + +**Interfaces:** `error_evidence(error, operation) -> dict` returns a bounded code and operation without raw diagnostics; `inventory_errors(inventory) -> dict` exposes component errors to report/permission consumers. Existing data keys remain compatible. + +- [x] Write regressions for denied DNS/adapter/route reads, unavailable tools, malformed responses, timeout and partial inventory. Assert consumer-visible report/assessment outcomes. +- [x] Run `python -m unittest tests.test_inventory_permissions -v`; observed the expected failures against 0.24.1 behavior. +- [x] Implement structured error propagation and partial-summary handling in the existing collectors/checks. +- [x] Run focused tests and full Python suite: 688 tests, OK, 5 platform skips. +- [x] Commit this independently testable correction and ledger its evidence. + +### Task 2: Scoped authorization and automatic continuation + +**Files:** Modify `permissions.py`, `operations.py`, `assessment.py`, `web/desktop.mjs`, `web/app.mjs`; create `web/permission_flow.mjs`, `tests/test_permissions.py`, `scripts/permission_flow_smoke.py`. + +**Interfaces:** `permission_help` returns capability IDs, scope, affected checks and an OS action while retaining legacy response fields. Frontend `initPermissionFlow({run,operate,recheck,getContext})` owns pending authorization, focus/visibility continuation, cancellation and retry; no permission is granted from a frontend flag. + +- [x] Write backend and browser regressions for routed capabilities, cancellation, changed target/selection, missing settings entry and continued denial. +- [x] Observe failures, implement scoped OS actions and bounded continuation with fresh audit/repair preview. +- [x] Classify OS consent/operation outcomes without discarding pending native transactions. +- [x] Verify 697 Python tests (5 platform skips), 19 JavaScript tests, desktop UI at 1024/1280/1440 and Windows/macOS permission workflow fixtures; commit and ledger results. Real OS consent remains separately unmeasured. + +### Task 3: Persistent guard authority and actionable notifications + +**Files:** Modify `watchdog.py`, `maintenance.py`, `timezone_service.py`, `desktop_runtime.py`, native build scripts; add Windows native timezone service/source, lifecycle adapter and corresponding fixture tests/CI acceptance. + +**Interfaces:** Guard preparation exposes per-component capability/authorization state; service adapters implement status/prepare/apply/recover/undo/remove using reviewed plan IDs and protected receipts. UI receives component states from these results rather than inferring authority from a saved baseline. + +- [x] Add regressions proving a matched baseline with insufficient write authority cannot be represented as automatic protection; cover revoke, cancel, tampered files, foreign owner and pending recovery. +- [x] Implement native Windows timezone service and one-time reviewed installation/update/removal; preserve existing macOS root service behavior and evaluate the supported SMAppService route. +- [x] Implement deduplicated actionable notifications with a direct return to the failed component. +- [x] Run isolated native service lifecycle and at least two automatic scheduler cycles on supported CI platforms, with original machine values unchanged; run full suites; commit and ledger evidence. + +### Task 4: Release identity, signatures and update verification + +**Files:** Modify `scripts/build_desktop.py`, native build scripts, `.github/workflows/desktop.yml`, updater code and release documentation; add signing/publisher verification helpers/tests. + +**Interfaces:** Build metadata carries stable `io.qudadd.envscope` identity, semantic/build versions and signed/unsigned mode. Signed release verification requires the configured publisher identity plus archive integrity; missing required signing credentials fail signed release builds. + +- [x] Add executable-package metadata and signer-verification regressions using isolated signed/unsigned/tampered fixtures. +- [x] Under the clarified open-source scope, ship unsigned/ad-hoc builds with fixed identity, native version resources and package identity validation. Document publisher signing/notarization as a future optional release mode; do not claim or silently accept unverified publisher identity. +- [x] Deliver the requested unsigned open-source build, verify identity continuity, and document optional signing configuration. Never label unsigned artifacts as publisher-signed or notarized. +- [x] Run local builds, package identity verification, update smoke tests and full suites; record unsigned status. Final macOS package metadata readback remains part of Task 7 distribution verification. + +### Task 5: Optimization acceptance and documentation + +**Files:** Update `docs/ARCHITECTURE.md`, `docs/DESKTOP_ACCEPTANCE.md`, permission/service docs and backend capability presentation. Create a checked requirement/evidence matrix under `docs/`. + +- [x] Reconcile each of the six source-audit priorities with source, regression and native evidence; fix uncovered behavior before checking it off. +- [x] Record the dedicated-device matrix for real standard-user/admin allow/cancel/revoke, service disable, reboot/sleep and upgrade as pending. Under the open-source Alpha scope, do not block the UI on unavailable human OS-dialog evidence or count fixtures as that evidence. +- [x] Verify notification deduplication, stop/revoke/uninstall and no loss of current features. +- [x] Update stale docs and capability descriptions from the actual implementation. Only mark this task complete when all optimization requirements or explicitly revised user requirements are satisfied. + +### Task 6: Complete desktop UI, icons and interaction refresh + +**Files:** `envscope/web/` screens/styles/modules, native app/tray assets, `desktop_runtime.py`, build icon wiring, walkthrough assets and UI smoke tests. + +**Interfaces:** Preserve existing backend capabilities/confirmation contracts. The visual layer renders states supplied by the backend and retains fresh report/repair/guard flows. + +- [x] Confirm Task 5 is complete. Read Impeccable context and the existing design constraints; document the concrete visual system. +- [x] Implement the complete minimal desktop layout and consistent application/tray/navigation/status icon set, with original licenses for reused assets. +- [x] Improve phase/progress feedback, keyboard/focus handling, stable selection/scroll, duplicate-operation prevention and recovery affordances across every screen. +- [x] Run real browser workflows at 1024/1280/1440 widths for Windows/macOS fixtures; inspect screenshots, keyboard access, overflow, empty/loading/error/permission/recovery states and frozen-package icon rendering. +- [x] Commit the verified UI, asset and interaction changes with documentation. + +### Task 7: Full review, native distribution and public delivery + +- [ ] Run full Python/JS/syntax/package/desktop workflow and privacy/publication checks for the final source. +- [ ] Request one independent whole-branch code/security review; fix verified findings with regressions. +- [ ] Build and verify Windows x64/macOS ARM64/macOS Intel open-source native distributions, recording their actual signature status. +- [ ] Update the local trial without stopping the user's running application, preserve rollback and publish the reviewed GitHub release under existing authorization. +- [ ] Read back the public release, asset hashes/signers and final local launcher. Audit every explicit requirement before marking the goal complete. diff --git a/docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md b/docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md new file mode 100644 index 0000000..792cc89 --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-permissions-and-ui-design.md @@ -0,0 +1,42 @@ +# Permission completion and desktop refresh + +## Intent and accepted scope + +Implement the permission/usability audit requested by the user, verify every optimization, then upgrade the complete desktop UI, application/tray/navigation icons and interaction responsiveness. Preserve Windows/macOS support, a monochrome minimal design and the primary flow: target → one check → problems/repair → persistent guard. Mobile remains out of scope. + +The source audit is `artifacts/permission-review-2026-10-30/AUDIT.md`; its six priorities are requirements, not completion evidence. Baseline: `ffbdc3a` / 0.24.1. + +## Architecture and approaches + +Use the existing Python, native helper and webview architecture. Keep the main process and account state in the original user's session. Use OS-owned consent for specific privileged operations and protected, narrowly scoped native helpers for persistent system work. Reuse backup, report freshness, transaction and independent readback gates. + +A permanently elevated application would unnecessarily elevate the browser interface and account operations. Guidance-only permission links cannot satisfy the requested automatic continuation/guard behavior. The chosen implementation extends the existing split-process architecture; this is an implementation decision under the user's requested scope. + +## Required outcomes + +1. Classify collection failure as permission_denied, policy_denied, timeout, tool_missing, invalid_response or unavailable. Preserve the failed operation in evidence; partial inventory never reports a completed pass. Propagate failures to dependent checks. Do not store raw command stderr or secrets. +2. Describe each requested capability, its affected checks, read/write/background scope and OS action. Provide only actions relevant to observed denial and the selected target. Merely opening settings is never permission_granted. Returning from a permission step triggers bounded capability revalidation and a new report/repair plan, while preserving the user's selected checks and repair receipt. +3. Distinguish cancelled consent, refused policy, an operation failure, pending recovery, restart required and verified success. Unknown execution must reconcile transaction records before retrying a mutation. +4. Check guard capabilities before activation. Windows gains a protected, restricted timezone worker/service for devices whose user token cannot maintain the approved timezone. macOS retains its root-owned worker and exposes actual registration/approval/heartbeat status; evaluate SMAppService integration against the supported platform floor. Background workers never open authorization dialogs. All components support stop/revoke/uninstall and readback. +5. Use stable app identity/version metadata, Windows publisher signing and macOS Developer ID signing/notarization in the release pipeline. Verify signer identity during signed update installation. Certificate integration requires the owner's credentials through an approved secret destination. A pending certificate question is not evidence of signing completion. +6. Exercise allow/cancel/revoke/policy denial, upgrade identity continuity, restart/sleep/wake and disabled service cases. Use isolated native fixtures for automated tests. Real UAC/TCC prompts require dedicated device evidence and an actual person interacting with the OS prompt; existing mocked dialogs are insufficient evidence. +7. Update stale architecture/support documentation and derive capability presentation from backend data. Notify only on actionable guard failures or revoked permission, with deduplication and a direct return to the affected operation. +8. After optimization acceptance, redesign all desktop screens and the app/tray/navigation icon family. Retain plain explanations, visible per-item results, select-all, one-click reviewed repairs and tutorial. Keep long operations asynchronous with specific phases, cancellation where supported, stable focus/scroll and no duplicate submissions. +9. Verify full Python/JS suites, native packages on Windows + macOS ARM64/Intel, desktop workflows, visual appearance, release contents and public readback. Update the local trial and publish under qudadd/envscope using existing authorization. + +## External prerequisites and phase gates + +On 2026-10-30 the user confirmed no signing credentials and clarified this is only an open-source project. Formal publisher signing and Apple notarization are optional and no longer delivery gates. Stable identity, honest unsigned-build status, archive integrity and scoped OS authorization remain required. Real device consent observations must remain separately marked pending where unavailable; never count fixture tests as real UAC/TCC evidence. Complete optimization implementation and its automated/native acceptance before the overall UI refresh. + +## Invariants + +- Tests/development do not alter real networking, timezone, locale, firewall, installed applications, accounts or proxy settings. +- Every actual product mutation discloses effects and requires boolean confirmation; stale or imported reports cannot authorize it. +- Native helpers execute a closed set of operations, use protected files and validate caller/owner, paths, hashes and transaction identity. Never run user-writable Python or upstream scripts with permanent elevation. +- Do not change vendor bytes or licenses; do not run vendor scripts/tests/installers in development. +- Unknown, unsupported, skipped, stale or malformed evidence is never a pass. A successful setting change is not proof of Claude account availability. +- Maintain the user's minimal desktop design and original feature coverage. + +## Acceptance evidence + +For each requirement, retain a failing regression (when code behavior changes), its passing result, relevant full-suite results and the actual native/runtime evidence. Store a completion matrix distinguishing implemented, fixture-verified, native-verified and external-pending. A green build is not an OS consent result, and a SHA-256 digest is not a publisher signature. diff --git a/envscope/__init__.py b/envscope/__init__.py index e54e6a8..987cc75 100644 --- a/envscope/__init__.py +++ b/envscope/__init__.py @@ -1,4 +1,4 @@ """EnvScope: evidence-first environment diagnostics.""" -__version__ = "0.24.1" +__version__ = "0.25.0" __release__ = "v" + __version__ + "-alpha.1" diff --git a/envscope/_desktop_test.py b/envscope/_desktop_test.py index 9a51037..a67fb17 100644 --- a/envscope/_desktop_test.py +++ b/envscope/_desktop_test.py @@ -128,8 +128,18 @@ def execute(self,*args):raise AssertionError('Frozen software fixture must not m result['native_components_worker'] = 'not_applicable' result['native_language_bindings']='not_applicable' result['native_pipe_transport']='not_applicable' + from .app_identity import verify_package_identity, build_identity + result['build_identity']=(verify_package_identity(sys.executable,platform.system(),__release__) + if getattr(sys,'frozen',False) else build_identity(platform.system())) + result['identity_source']='package' if getattr(sys,'frozen',False) else 'source-fixture' result['native_wireguard_bindings']='not_applicable' if platform.system()=='Windows': + from .timezone_service import bundle_worker + import subprocess + worker=bundle_worker(cache=directory/'native-timezone-cache',system='Windows') + info=json.loads(subprocess.check_output([worker['path'],'--version'],timeout=10)) + assert info=={'format':1,'release':__release__,'fixture':False} + result['native_timezone_worker']=True from .wireguard import WindowsServices, command_identity, PREFIX path = 'C:\\Program Files\\WireGuard\\wireguard.exe' args = WindowsServices().argv('"'+path+'" /tunnelservice "C:\\EnvScopeFixture.conf"') @@ -247,8 +257,8 @@ def exercise(): ids = read_js("[...document.querySelectorAll('[id]')].map(n=>n.id)") assert len(ids) == len(set(ids)), 'Duplicate DOM IDs' # Exercise the actual menu callback and its CSP-compatible JS. - runtime.icon.menu.items[1](runtime.icon) - assert read_js("document.getElementById('page-settings').hidden === false") + next(item for item in runtime.icon.menu.items if item.text=='软件更新与后台设置')(runtime.icon) + assert read_js("document.getElementById('page-settings').hidden === false"), 'Tray settings action must open settings' result['tray_settings'] = True assert runtime.closing() is False result['close_hides'] = True diff --git a/envscope/app_identity.py b/envscope/app_identity.py new file mode 100644 index 0000000..deaf142 --- /dev/null +++ b/envscope/app_identity.py @@ -0,0 +1,42 @@ +"""Stable open-source package identity; metadata is not a publisher signature.""" +import json +from pathlib import Path +import re + +from . import __release__, __version__ + +APP_ID='io.qudadd.envscope' + + +def build_identity(system): + if system not in ('Windows','Darwin'):raise ValueError('Unsupported desktop platform') + return {'format':1,'app_id':APP_ID,'version':__version__,'release':__release__, + 'build_version':__version__,'system':system, + 'signature_mode':'ad-hoc' if system=='Darwin' else 'unsigned', + 'publisher_verified':False,'notarized':False} + + +def validate_identity(value,system,release): + expected='ad-hoc' if system=='Darwin' else 'unsigned' + if (not isinstance(value,dict) or type(value.get('format')) is not int or value['format']!=1 + or value.get('app_id')!=APP_ID or value.get('release')!=release or value.get('system')!=system + or not re.fullmatch(r'v\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.\d+)?',release) + or value.get('version')!=release[1:].split('-')[0] or value.get('build_version')!=value.get('version') + or value.get('signature_mode')!=expected or value.get('publisher_verified') is not False + or value.get('notarized') is not False): + raise ValueError('更新包应用身份、版本或开源构建标识不符合当前发布规则,已停止安装。') + return value + + +def identity_path(executable,system): + executable=Path(executable) + return (executable.parent.parent/'Resources' if system=='Darwin' else executable.parent)/'build-identity.json' + + +def verify_package_identity(executable,system,release): + path=identity_path(executable,system) + if path.is_symlink() or not path.is_file() or path.stat().st_size>16384: + raise ValueError('更新包缺少有效的应用身份记录,已停止安装。') + try:value=json.loads(path.read_text(encoding='utf-8')) + except (ValueError,UnicodeError):raise ValueError('应用身份记录无效。') from None + return validate_identity(value,system,release) diff --git a/envscope/assessment.py b/envscope/assessment.py index fe8a8f3..76ff885 100644 --- a/envscope/assessment.py +++ b/envscope/assessment.py @@ -3,7 +3,7 @@ def denied(value): if isinstance(value, dict): - return value.get("error") == "permission_denied" or any(denied(v) for v in value.values()) + return value.get("error") in ('permission_denied', 'policy_denied') or any(denied(v) for v in value.values()) return isinstance(value, list) and any(denied(v) for v in value) diff --git a/envscope/baseline.py b/envscope/baseline.py index a47b0ec..30a6b58 100644 --- a/envscope/baseline.py +++ b/envscope/baseline.py @@ -11,6 +11,7 @@ from .model import Finding, Status from .policy import normalize_ip from .transport import response_evidence +from .observation_errors import inventory_errors DOMAINS = ("claude.ai", "claude.com", "anthropic.com", "claudeusercontent.com", "challenges.cloudflare.com", "datadoghq.com", "segment.io", "stripe.com", "stripe.network", "stripecdn.com", @@ -34,8 +35,9 @@ def proxy_runtime(inventory, preferences): names = {"clash-verge": ("mihomo", "verge-mihomo"), "flclash": ("flclashcore",), "sing-box": ("sing-box",), "shadowrocket": ("shadowrocket",), "clashx": ("clashx",), "surge": ("surge",)} matches = clients if expected == "auto" else [c for c in clients if str(c).lower() in names.get(expected, ())] - return baseline_finding("proxy", "代理客户端", Status.PASS if matches else Status.UNKNOWN, - "已发现所选代理客户端进程" if matches else "未确认所选代理客户端进程", [{"clients": clients}], + errors = inventory_errors(inventory, 'known_clients') + return baseline_finding("proxy", "代理客户端", Status.PASS if matches and not errors else Status.UNKNOWN, + '进程信息读取不完整' if errors else "已发现所选代理客户端进程" if matches else "未确认所选代理客户端进程", [{"clients": clients, 'collection_errors': errors}], ["进程存在不等于流量已接管。"]) @@ -47,8 +49,9 @@ def tunnel(inventory): up = adapter.get("Status") == "Up" or "UP" in adapter.get("flags", []) if up and re.search(r"TUN|TAP|Wintun|WireGuard|VPN|Tailscale|utun", description, re.I): candidates.append({"name": adapter.get("Name", adapter.get("name")), "active": True}) - return baseline_finding("tun", "TUN 网卡", Status.PASS if candidates else Status.UNKNOWN, - "发现启用的隧道网卡" if candidates else "未确认启用的隧道网卡", candidates, + errors = inventory_errors(inventory, 'adapters') + return baseline_finding("tun", "TUN 网卡", Status.PASS if candidates and not errors else Status.UNKNOWN, + '网卡信息读取不完整' if errors else "发现启用的隧道网卡" if candidates else "未确认启用的隧道网卡", candidates + ([{'collection_errors': errors}] if errors else []), ["这是网卡状态,目标流量关联另行显示。"]) @@ -236,23 +239,28 @@ def collect_baseline(inventory, v4, v6, udp_result, browser_result, preferences, timezone = inventory.get("network", {}).get("timezone_id") or inventory.get("timezone_iana") locale = inventory.get("locale") def setting(name, title, actual, expected): + errors = inventory_errors(inventory, name) if name in ('timezone', 'locale', 'language') else {} equal = str(actual).replace("_", "-").lower() == str(expected).replace("_", "-").lower() - state = Status.PASS if expected and equal else Status.WARN if not expected else Status.UNKNOWN if not actual else Status.FAIL - return baseline_finding(name, title, state, "符合设置的目标" if state == Status.PASS else "未设置比较目标,仅展示当前信息" if not expected else "当前信息与目标不同" if actual else "未取得当前信息", - [{"actual": actual, "expected": expected}], ["地区和语言差异不代表账号风险。"]) + state = Status.UNKNOWN if errors else Status.PASS if expected and equal else Status.WARN if not expected else Status.UNKNOWN if not actual else Status.FAIL + return baseline_finding(name, title, state, '当前信息读取不完整' if errors else "符合设置的目标" if state == Status.PASS else "未设置比较目标,仅展示当前信息" if not expected else "当前信息与目标不同" if actual else "未取得当前信息", + [{"actual": actual, "expected": expected, 'collection_errors': errors}], ["地区和语言差异不代表账号风险。"]) network = inventory.get("network", {}) bindings = network.get("ipv6_bindings", []) services = network.get("ipv6_services", []) + ipv6_errors = inventory_errors(inventory, 'ipv6_bindings', 'ipv6_services', 'adapters') disabled = (bool(bindings) and all(b.get("Enabled") is False for b in bindings) or bool(services) and all(s.get("ipv6_off") is True for s in services)) valid_v6 = [o for o in v6 if o.get("ip") and ":" in o["ip"]] allowed = [ipaddress.ip_network(n) for n in preferences.get("allowed_ipv6", [])] policy_matches = bool(valid_v6 and allowed) and all(any(normalize_ip(o["ip"]) in n for n in allowed) for o in valid_v6) - ipv6_status = Status.PASS if disabled or policy_matches else Status.UNKNOWN if not valid_v6 else Status.WARN + disabled = disabled and not ipv6_errors + ipv6_status = Status.PASS if disabled or policy_matches else Status.UNKNOWN if ipv6_errors or not valid_v6 else Status.WARN + dns_errors = inventory_errors(inventory, 'dns') + dns_ok = bool(network.get('dns')) and not dns_errors language = inventory.get("client_language", {}) language_result = (setting("language", "客户端语言", language.get("value"), preferences.get("language") if preferences.get('environment_confirmed') else preferences.get("locale")) if language.get("source") == "application-preference" else baseline_finding("language", "客户端语言", Status.UNKNOWN, - "未取得客户端专用语言设置", [{"system_ui_language": language.get("value")}], ["系统语言不能代替 Claude 的实际语言偏好。分享时可不包含此项。 "])) + "未取得客户端专用语言设置", [{"system_ui_language": language.get("value"), 'collection_errors': inventory_errors(inventory, 'language'), **({'error': language['error']} if language.get('error') else {})}], ["系统语言不能代替 Claude 的实际语言偏好。分享时可不包含此项。 "])) rtc_states = {udp_result.status, browser_result.status} rtc_status = (Status.FAIL if Status.FAIL in rtc_states else Status.UNKNOWN if rtc_states & {Status.UNKNOWN, Status.SKIPPED} else Status.WARN if Status.WARN in rtc_states else Status.PASS) @@ -260,9 +268,9 @@ def setting(name, title, actual, expected): baseline_finding("ip_stability", "出口变化", Status.UNKNOWN, "在历史记录中比较检测前后的出口集合", limitations=["历史使用私有 HMAC,不公开原始地址。"]), setting("timezone", "时区", timezone, preferences.get("timezone")), setting("locale", "地区格式", locale, preferences.get("locale")), - baseline_finding("ipv6", "IPv6 配置", ipv6_status, "系统报告已关闭已检查接口的 IPv6" if disabled else "有效观测符合指定 IPv6 范围" if policy_matches else "IPv6 是否符合预期需结合实际出口复核", [{"checked_interfaces_disabled": disabled}], ["IPv6 启用本身不构成泄漏;无响应也不是已关闭证明。"]), - baseline_finding("dns", "DNS 配置", Status.PASS if network.get("dns") else Status.UNKNOWN, - "已读取系统 DNS 配置" if network.get("dns") else "未取得 DNS 配置", [{"configured": bool(network.get("dns"))}], ["实际 DNS 出口由独立检查判断,配置存在不证明无泄漏。"]), + baseline_finding("ipv6", "IPv6 配置", ipv6_status, "系统报告已关闭已检查接口的 IPv6" if disabled else "有效观测符合指定 IPv6 范围" if policy_matches else "IPv6 是否符合预期需结合实际出口复核", [{"checked_interfaces_disabled": disabled, 'collection_errors': ipv6_errors}], ["IPv6 启用本身不构成泄漏;无响应也不是已关闭证明。"]), + baseline_finding("dns", "DNS 配置", Status.PASS if dns_ok else Status.UNKNOWN, + "已读取系统 DNS 配置" if dns_ok else "未取得完整 DNS 配置", [{"configured": bool(network.get("dns")), 'collection_errors': dns_errors}], ["实际 DNS 出口由独立检查判断,配置存在不证明无泄漏。"]), baseline_finding("webrtc", "UDP / WebRTC", rtc_status, "系统 UDP 与浏览器 ICE 分别比较完成" if rtc_status == Status.PASS else "系统 UDP 与浏览器 ICE 需分别查看测量细项", [{"native_udp": udp_result.status.value, "browser_ice": browser_result.status.value}], ["两种探测不互相替代;无响应不能证明没有旁路。"]), diff --git a/envscope/components_macos.py b/envscope/components_macos.py index 9b8b322..5ea9453 100644 --- a/envscope/components_macos.py +++ b/envscope/components_macos.py @@ -94,4 +94,5 @@ def execute(self, action, payload): code += f'/usr/bin/env -i PATH=/usr/bin:/bin:/usr/sbin:/sbin LC_ALL=C "$STAGE/helper" --{action} "$STAGE/plan.plist" {shlex.quote(hashlib.sha256(raw).hexdigest())} {shlex.quote(str(self.uid))}\n' apple = 'do shell script '+json.dumps(code, ensure_ascii=False)+' with administrator privileges without altering line endings' result = self.runner(['/usr/bin/osascript', '-e', apple], capture_output=True, timeout=150) - if result.returncode: raise ValueError('系统授权取消或旧组件操作未完全完成,请刷新并核对恢复记录') + from .consent import raise_for_consent + raise_for_consent(result, 'Darwin') diff --git a/envscope/components_windows.py b/envscope/components_windows.py index 3921f6d..1a9f5fa 100644 --- a/envscope/components_windows.py +++ b/envscope/components_windows.py @@ -242,7 +242,8 @@ def execute(self, action, payload): "$o=@{FilePath="+quote(self.executable)+";ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$code);WindowStyle='Hidden';Wait=$true;PassThru=$true};" "$p=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent());" "if(-not $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){$o.Verb='RunAs'};" - "$v=Start-Process @o;exit $v.ExitCode") + "try{$v=Start-Process @o;exit $v.ExitCode}catch{$e=$_.Exception;while($e){if($e -is [ComponentModel.Win32Exception]){exit $e.NativeErrorCode};$e=$e.InnerException};exit 1}") result = self.runner([self.executable, '-NoProfile', '-NonInteractive', '-Command', launcher], input=encoded.encode(), capture_output=True, timeout=150, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) - if result.returncode: raise ValueError('系统授权取消或旧组件操作未完成,请核对受保护的恢复记录') + from .consent import raise_for_consent + raise_for_consent(result, 'Windows') diff --git a/envscope/consent.py b/envscope/consent.py new file mode 100644 index 0000000..7dc14c2 --- /dev/null +++ b/envscope/consent.py @@ -0,0 +1,36 @@ +"""Classify OS consent without equating command completion with a repair.""" +import re +import subprocess + +MESSAGES = { + 'consent_cancelled': '系统授权已取消。请刷新实际状态,已有事务仍需按恢复记录核对。', + 'policy_denied': '组织策略阻止了操作。请联系管理员处理,并保留恢复记录。', + 'permission_denied': '系统拒绝了所需权限。请核对授权及恢复记录。', + 'operation_failed': '系统操作未完成。请核对实际状态及恢复记录后再重试。', + 'execution_unknown': '等待系统操作超时,执行结果尚不确定。请先刷新状态及恢复记录,勿重复提交。', +} + + +class ConsentError(ValueError): + def __init__(self, code): + self.code = code + super().__init__(MESSAGES[code]) + + +def raise_for_consent(result, system): + if result.returncode == 0: + return + stderr = result.stderr or '' + if isinstance(stderr, bytes): + stderr = stderr.decode('utf-8', errors='replace') + # Only the osascript native error suffix, never the word "cancelled". + mac_cancel = system == 'Darwin' and re.search(r'\(-128\)\s*$', stderr[-1024:]) + code = ('consent_cancelled' if system == 'Windows' and result.returncode == 1223 or mac_cancel else + 'policy_denied' if system == 'Windows' and result.returncode == 1260 else + 'permission_denied' if system == 'Windows' and result.returncode == 5 else 'operation_failed') + raise ConsentError(code) + + +def operation_outcome(error): + code = error.code if isinstance(error, ConsentError) else 'execution_unknown' if isinstance(error, (subprocess.TimeoutExpired, TimeoutError)) else 'permission_denied' if isinstance(error, PermissionError) else 'operation_failed' + return {'error': code, 'needs_retest': True} diff --git a/envscope/desktop_runtime.py b/envscope/desktop_runtime.py index 864ba67..dea75a7 100644 --- a/envscope/desktop_runtime.py +++ b/envscope/desktop_runtime.py @@ -73,6 +73,41 @@ def __init__(self, server, window, updates=None): self.preferences = {k: saved.get(k, v) if type(saved.get(k, v)) is bool else v for k, v in DEFAULTS.items()} self.icon, self.quitting, self.next_executable = None, False, None self.ready, self.stopped = threading.Event(), threading.Event() + self.guard_attention=None + from .guard_notifications import GuardNotifications + self.guard_notifications=GuardNotifications(server.store,self.notify_guard) + + def notify_guard(self,value): + self.guard_attention=value + if self.icon: + try: + if getattr(self.icon,'HAS_NOTIFICATION',False):self.icon.notify(value['message'],'EnvScope 守护提醒') + self.icon.update_menu() + except (OSError,RuntimeError,NotImplementedError):pass + # Details remain in local records. The fixed event only carries known IDs. + try:self.window.run_js('document.dispatchEvent(new CustomEvent("envscope-guard-attention",{detail:'+json.dumps(value,ensure_ascii=True)+'}))') + except (OSError,RuntimeError):pass + + def show_guard(self): + self.show() + self.window.run_js('document.dispatchEvent(new CustomEvent("envscope-open-guard"))') + + def poll_guard(self): + operations=getattr(self.server,'operations',None) + if operations is None or not self.ready.is_set():return + lock=getattr(self.server,'audit_lock',None) + if lock is not None and lock.locked():return + maintenance=operations.maintenance.status();timezone=operations.timezone_service.status() + self.guard_notifications.update(maintenance,timezone) + if self.guard_notifications.current and self.guard_attention!=self.guard_notifications.current: + # Restore the in-app state after restart without a duplicate toast. + self.guard_attention=self.guard_notifications.current + self.window.run_js('document.dispatchEvent(new CustomEvent("envscope-guard-attention",{detail:'+json.dumps(self.guard_attention,ensure_ascii=True)+'}))') + if self.icon:self.icon.update_menu() + if self.guard_attention and not self.guard_notifications.active: + self.guard_attention=None + self.window.run_js('document.dispatchEvent(new CustomEvent("envscope-guard-clear"))') + if self.icon:self.icon.update_menu() def status(self): installed = self.server.store._read("desktop-install.private.json", {}) @@ -156,22 +191,23 @@ def run(self, action, params, confirmed): def background(self): # This is update discovery, not implicit diagnostic/maintenance consent. - last = None + last = None;guard_checked=0 while not self.stopped.wait(10): if self.preferences["check_updates"] and (last is None or time.monotonic() - last > 24 * 3600): self.updates.check(self.preferences["preview_updates"]) last = time.monotonic() if self.icon: self.icon.update_menu() + if time.monotonic()-guard_checked>=30: + try:self.poll_guard() + except (OSError,ValueError,RuntimeError,subprocess.SubprocessError):pass + guard_checked=time.monotonic() def start_tray(self): import pystray - from PIL import Image, ImageDraw - image = Image.new("RGBA", (64, 64), (0, 0, 0, 0)) - draw = ImageDraw.Draw(image) - draw.rounded_rectangle((4, 4, 60, 60), radius=12, fill="white", outline="black", width=3) - for rect in ((19, 17, 25, 47), (25, 17, 45, 23), (25, 29, 40, 35), (25, 41, 45, 47)): - draw.rectangle(rect, fill="black") + from PIL import Image + with Image.open(Path(__file__).parent/'web/tray.png') as source: + image=source.convert('RGBA') def open_settings(icon, item): self.show() self.window.run_js("document.querySelector('[data-page=\"settings\"]').click()") @@ -180,6 +216,7 @@ def ready(icon): self.ready.set() self.icon = pystray.Icon("EnvScope", image, "EnvScope", pystray.Menu( pystray.MenuItem("打开 EnvScope", lambda icon, item: self.show(), default=True), + pystray.MenuItem(lambda item:'守护需要处理' if self.guard_attention else '查看守护状态',lambda icon,item:self.show_guard()), pystray.MenuItem(lambda item: "有新版可用" if self.updates.status()["state"] == "available" else "软件更新与后台设置", open_settings), pystray.MenuItem("退出 EnvScope", lambda icon, item: self.quit()))) self.icon.run_detached(setup=ready) @@ -227,6 +264,10 @@ def native_main(server): except (AccountError,OSError,ValueError): pass # The read-only catalog exposes unreadable records for review. url = "http://127.0.0.1:" + str(server.server_port) + if platform.system()=='Windows': + import ctypes + from .app_identity import APP_ID + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID) webview.settings.update(ALLOW_DOWNLOADS=True, ALLOW_FILE_URLS=False, OPEN_EXTERNAL_LINKS_IN_BROWSER=True) window = webview.create_window("EnvScope", url, width=1180, height=800, min_size=(960, 640)) runtime = DesktopRuntime(server, window) diff --git a/envscope/guard_capabilities.py b/envscope/guard_capabilities.py new file mode 100644 index 0000000..e076f89 --- /dev/null +++ b/envscope/guard_capabilities.py @@ -0,0 +1,48 @@ +"""Read current-token privileges; never enable rights or modify policy here.""" +import platform + + +def write_capabilities(system=None): + system = system or platform.system() + timezone = {'state': 'authorization_required', 'source': 'system-service', + 'reason': '系统时区保持需要单独授权受保护的后台任务。'} + if system == 'Windows' and platform.system() == 'Windows': + try: + import ctypes + from ctypes import wintypes + class LUID(ctypes.Structure): + _fields_ = [('LowPart', wintypes.DWORD), ('HighPart', wintypes.LONG)] + class Entry(ctypes.Structure): + _fields_ = [('Luid', LUID), ('Attributes', wintypes.DWORD)] + advapi = ctypes.WinDLL('advapi32', use_last_error=True) + kernel = ctypes.WinDLL('kernel32', use_last_error=True) + kernel.GetCurrentProcess.restype = wintypes.HANDLE + kernel.CloseHandle.argtypes = [wintypes.HANDLE] + advapi.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, ctypes.POINTER(wintypes.HANDLE)] + advapi.LookupPrivilegeValueW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.POINTER(LUID)] + advapi.GetTokenInformation.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD)] + token = wintypes.HANDLE(); luid = LUID(); size = wintypes.DWORD() + if not advapi.OpenProcessToken(kernel.GetCurrentProcess(), 8, ctypes.byref(token)): + raise OSError('token unavailable') + try: + if not advapi.LookupPrivilegeValueW(None, 'SeTimeZonePrivilege', ctypes.byref(luid)): + raise OSError('privilege unavailable') + advapi.GetTokenInformation(token, 3, None, 0, ctypes.byref(size)) + if not 4 <= size.value <= 65536: + raise OSError('invalid token size') + data = ctypes.create_string_buffer(size.value) + if not advapi.GetTokenInformation(token, 3, data, size, ctypes.byref(size)): + raise OSError('token unavailable') + count = wintypes.DWORD.from_buffer(data).value + if 4 + count * ctypes.sizeof(Entry) > size.value: + raise OSError('invalid token') + entries = (Entry * count).from_buffer(data, 4) + present = any(row.Luid.LowPart == luid.LowPart and row.Luid.HighPart == luid.HighPart and not row.Attributes & 4 for row in entries) + timezone = {'state': 'available' if present else 'authorization_required', 'source': 'user-token', + 'reason': '当前用户令牌具备更改时区权限;每次后台运行仍会复核。' if present else '当前用户没有更改时区权限,启用保持前需要授权系统时区任务。'} + finally: + kernel.CloseHandle(token) + except (OSError, ValueError, ImportError): + timezone = {'state': 'unavailable', 'source': 'user-token', 'reason': '未能核实后台时区权限,不能声明可自动恢复。'} + return {'timezone': timezone, 'locale': {'state': 'available', 'source': 'user-session', + 'reason': '仅在原用户会话中恢复地区格式。'}} diff --git a/envscope/guard_notifications.py b/envscope/guard_notifications.py new file mode 100644 index 0000000..fd205e8 --- /dev/null +++ b/envscope/guard_notifications.py @@ -0,0 +1,47 @@ +"""Notify once for actionable transitions, excluding logs and private values.""" +import hashlib +import json +import time + +LABELS={'timezone':'时区','locale':'地区格式','proxy':'代理','code':'Code 配置','scheduler':'后台巡检'} + + +class GuardNotifications: + def __init__(self,store,sink,clock=time.time): + self.store,self.sink=store,sink + self.clock=clock + self.saved=store._read('guard-notifications.private.json',{}) + if not isinstance(self.saved,dict):self.saved={} + self.active=False + self.current=None + + def update(self,maintenance,timezone): + saved=self.saved + expected=saved.get('timezone_expected',False) + if timezone.get('installed') is True:expected=True + elif timezone.get('intentionally_removed') is True:expected=False + waiting=saved.get('waiting_since') + if expected and timezone.get('state')=='waiting': + if not isinstance(waiting,(int,float)) or waiting>self.clock():waiting=self.clock() + else:waiting=None + issues={} + if maintenance.get('enabled') is True: + for name,row in maintenance.get('watchdog',{}).items(): + if name in LABELS and row.get('state') in ('attention','paused'): + issues[name]=row['state'] + if maintenance.get('state') in ('failed','stale','schedule_unverified'):issues['scheduler']=maintenance['state'] + if maintenance.get('restore_state') in ('blocked','unverified','paused'):issues['code']=maintenance['restore_state'] + if expected and timezone.get('state') in ('attention','conflict','recovery_required','unregistered','outdated'): + issues['timezone']=timezone['state'] + if expected and timezone.get('state')=='not_installed':issues['timezone']='unexpected_removal' + if waiting is not None and self.clock()-waiting>=180:issues['timezone']='heartbeat_lost' + fingerprint=hashlib.sha256(json.dumps(issues,sort_keys=True).encode()).hexdigest() if issues else '' + changed=bool(issues and fingerprint!=saved.get('fingerprint')) + names=sorted(issues) + self.current={'components':names,'message':'、'.join(LABELS[name] for name in names)+'需要处理。打开守护方案查看原因与恢复入口。'} if issues else None + if changed: + self.sink(self.current) + self.active=bool(issues) + self.saved={'fingerprint':fingerprint,'timezone_expected':expected,'waiting_since':waiting} + if self.saved!=saved:self.store._write('guard-notifications.private.json',self.saved) + return changed diff --git a/envscope/inventory.py b/envscope/inventory.py index 8dad26a..3c06d57 100644 --- a/envscope/inventory.py +++ b/envscope/inventory.py @@ -11,19 +11,20 @@ import subprocess import time +from .observation_errors import check_command, error_evidence + def command_json(command, timeout=8): result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - if result.returncode or len(result.stdout) > 262144: - raise OSError("inventory command failed") + check_command(result) return json.loads(result.stdout) def command_text(command, timeout=5): - result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout) - if result.returncode or len(result.stdout) > 262144: - raise OSError("inventory command failed") + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, + env={**os.environ, 'LC_ALL': 'C'}, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + check_command(result) return result.stdout @@ -42,23 +43,33 @@ def parse_macos_interfaces(text): def macos_network(): - network = {"adapters": parse_macos_interfaces(command_text(["/sbin/ifconfig", "-a"]))} + network = {"errors": {}} + try: + network['adapters'] = parse_macos_interfaces(command_text(['/sbin/ifconfig', '-a'])) + if not network['adapters']: + raise ValueError('no recognized interfaces') + except (OSError, ValueError, subprocess.TimeoutExpired) as exc: + network['adapters'] = [] + network['errors']['adapters'] = error_evidence(exc, 'adapters') try: processes = command_text(["/bin/ps", "-A", "-o", "comm="]) known = {"mihomo", "verge-mihomo", "flclashcore", "sing-box", "shadowrocket", "clashx", "surge", "wireguard", "tailscaled"} network["known_clients"] = sorted({Path(p.strip()).name for p in processes.splitlines() if Path(p.strip()).name.lower() in known}) - except (OSError, subprocess.TimeoutExpired): + except (OSError, subprocess.TimeoutExpired) as exc: network["known_clients"] = [] + network['errors']['known_clients'] = error_evidence(exc, 'known_clients') try: dns = command_text(["/usr/sbin/scutil", "--dns"]) network["dns"] = sorted(set(re.findall(r"nameserver\[\d+\]\s*:\s*(\S+)", dns))) - except (OSError, subprocess.TimeoutExpired): - network["dns_error"] = "unavailable" + except (OSError, subprocess.TimeoutExpired) as exc: + network['errors']['dns'] = error_evidence(exc, 'dns') try: route = command_text(["/sbin/route", "-n", "get", "default"]) network["default_route"] = {k: v for k, v in re.findall(r"^\s*(gateway|interface):\s*(\S+)", route, re.M)} - except (OSError, subprocess.TimeoutExpired): - network["default_route_error"] = "unavailable" + if not network['default_route'].get('interface'): + raise ValueError('no recognized route') + except (OSError, ValueError, subprocess.TimeoutExpired) as exc: + network['errors']['routes'] = error_evidence(exc, 'routes') try: order = command_text(["/usr/sbin/networksetup", "-listnetworkserviceorder"]) active = {a["name"] for a in network["adapters"] if "UP" in a["flags"] and a["name"].startswith("en") @@ -69,12 +80,18 @@ def inspect(name): try: info = command_text(["/usr/sbin/networksetup", "-getinfo", name], timeout=3) return {"ipv6_off": bool(re.search(r"^IPv6:\s*Off\s*$", info, re.M))} - except (OSError, subprocess.TimeoutExpired): - return {"ipv6_off": None} + except (OSError, subprocess.TimeoutExpired) as exc: + return {'ipv6_off': None, **error_evidence(exc, 'ipv6_services')} with ThreadPoolExecutor(max_workers=4) as pool: network["ipv6_services"] = list(pool.map(inspect, services)) - except (OSError, subprocess.TimeoutExpired): + failures = [row for row in network['ipv6_services'] if row.get('error')] + if failures: + # Keep every independent failure; the summary uses one bounded code. + first = next((row for row in failures if row['error'] == 'permission_denied'), failures[0]) + network['errors']['ipv6_services'] = {'error': first['error'], 'operation': 'ipv6_services'} + except (OSError, subprocess.TimeoutExpired) as exc: network["ipv6_services"] = [] + network['errors']['ipv6_services'] = error_evidence(exc, 'ipv6_services') return network @@ -86,7 +103,7 @@ def collect(): if platform.system() == "Windows": shell = shutil.which("powershell.exe") if not shell: - raise OSError("PowerShell unavailable") + raise FileNotFoundError("PowerShell unavailable") script = r""" $ErrorActionPreference='Stop' [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false) @@ -94,7 +111,8 @@ def collect(): function Read-Part($name, [scriptblock]$read) { try { & $read } catch { $denied=($_.Exception -is [System.UnauthorizedAccessException]) -or ([string]$_.CategoryInfo.Category -eq 'PermissionDenied') - $failures[$name]=@{error=$(if($denied){'permission_denied'}else{'unavailable'})} + $missing=($_.Exception -is [System.Management.Automation.CommandNotFoundException]) + $failures[$name]=@{error=$(if($denied){'permission_denied'}elseif($missing){'tool_missing'}else{'unavailable'});operation=$name} } } $v=@{ @@ -105,35 +123,39 @@ def collect(): timezone_id=(Read-Part 'timezone' {(Get-TimeZone).Id}) ui_language=(Read-Part 'language' {(Get-UICulture).Name}) region_locale=(Read-Part 'locale' {(Get-Culture).Name}) - ipv6_bindings=@(Get-NetAdapterBinding -ComponentID ms_tcpip6 -ErrorAction SilentlyContinue | Select-Object Name,Enabled) - known_clients=@(Get-Process -Name 'mihomo','verge-mihomo','FlClashCore','sing-box','wireguard','openvpn','tailscaled' -ErrorAction SilentlyContinue | Select-Object -ExpandProperty ProcessName -Unique) - desktop_packages=@(Get-AppxPackage -Name '*Claude*' -ErrorAction SilentlyContinue | Select-Object Name,Version) + ipv6_bindings=@(Read-Part 'ipv6_bindings' {Get-NetAdapterBinding -ComponentID ms_tcpip6 | Select-Object Name,Enabled}) + known_clients=@(Read-Part 'known_clients' {Get-Process | Where-Object {$_.ProcessName -in @('mihomo','verge-mihomo','FlClashCore','sing-box','wireguard','openvpn','tailscaled')} | Select-Object -ExpandProperty ProcessName -Unique}) + desktop_packages=@(Read-Part 'desktop_packages' {Get-AppxPackage -Name '*Claude*' | Select-Object Name,Version}) } $v.errors=$failures ConvertTo-Json -InputObject $v -Depth 6 -Compress """ encoded = base64.b64encode(script.encode("utf-16le")).decode("ascii") result["network"] = command_json([shell, "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]) + if not isinstance(result['network'], dict): + result.pop('network') + raise ValueError('invalid inventory response') result["locale"] = result["network"].get("region_locale", result["locale"]) result["client_language"] = {"value": result["network"].get("ui_language"), "source": "system-fallback"} elif platform.system() == "Darwin": result["network"] = macos_network() try: result["locale"] = command_text(["/usr/bin/defaults", "read", "-g", "AppleLocale"]).strip() - except (OSError, subprocess.TimeoutExpired): - pass + except (OSError, subprocess.TimeoutExpired) as exc: + result['network']['errors']['locale'] = error_evidence(exc, 'locale') try: from .mac_preferences import read_claude_language result['client_language']=read_claude_language() - except (OSError,ValueError,RuntimeError): - result['client_language']={'value':None,'source':'unavailable','error':'application-preference-unavailable'} + except (OSError,ValueError,RuntimeError,subprocess.TimeoutExpired) as exc: + result['client_language']={'value':None,'source':'unavailable',**error_evidence(exc, 'client_language')} + result['network']['errors']['language'] = error_evidence(exc, 'language') localtime = str(Path("/etc/localtime").resolve()) if "/zoneinfo/" in localtime: result["timezone_iana"] = localtime.split("/zoneinfo/", 1)[1] else: result["network_error"] = "platform_network_adapter_not_implemented" - except (OSError, subprocess.TimeoutExpired, ValueError): - result["network_error"] = "inventory_unavailable_or_deadline" + except (OSError, subprocess.TimeoutExpired, ValueError) as exc: + result['network_error'] = error_evidence(exc, 'inventory') if result['platform'] == 'Windows': from .wireguard import WireGuardReader result.setdefault('network', {})['wireguard'] = WireGuardReader().collect() diff --git a/envscope/maintenance.py b/envscope/maintenance.py index bc9a837..ff21a04 100644 --- a/envscope/maintenance.py +++ b/envscope/maintenance.py @@ -150,6 +150,7 @@ def prepare(self, report, interval=15, auto_restore=False, watchdog=None): return {"state": "blocked", "reason": "基线代理的 API TLS 连接尚未验证,不启用自动配置恢复。先测试并修复代理。"} baseline,guard_effects={},[] timezone_plan=None + needs_timezone_service=False if watchdog: from .regions import region_finding, PARTIAL if (not self.store.settings().get('environment_confirmed') or not self.store.settings().get('usage_country') @@ -157,10 +158,13 @@ def prepare(self, report, interval=15, auto_restore=False, watchdog=None): or self.store.settings().get('usage_country') in PARTIAL or not any(f['id']=='target.region' and f['status']=='pass' for f in report.get('findings',[]))): return {'state':'blocked','reason':'请先确认官方支持的实际地区,并修复环境后保存守护基线。'} - baseline,guard_effects=self.watchdog.prepare(self.store.settings(),watchdog) - if baseline.get('timezone') and self.timezone_service and self.timezone_service.system=='Darwin': + baseline,guard_effects=self.watchdog.prepare(self.store.settings(),watchdog,allow_authorization=self.timezone_service is not None) + if baseline.get('timezone') and self.timezone_service: status=self.timezone_service.status() - if not (status.get('installed') and status.get('registered') and status.get('owner') + if status.get('state') in ('conflict','recovery_required') or status.get('state')=='attention' and 'installed' not in status: + return {'state':'blocked','reason':status.get('reason','系统时区执行能力无法核实,请先处理。')} + needs_timezone_service=(self.timezone_service.system=='Darwin' or status.get('installed') or self.watchdog.capability_reader().get('timezone',{}).get('state')!='available') + if needs_timezone_service and not (status.get('installed') and status.get('registered') and status.get('owner') and status.get('target')==baseline['timezone'] and not status.get('needs_update') and status.get('state') in ('active','waiting')): timezone_plan=self.timezone_service.prepare(report) @@ -186,10 +190,10 @@ def prepare(self, report, interval=15, auto_restore=False, watchdog=None): if baseline:effects.append('守护每项至少间隔 5 分钟、24 小时最多尝试恢复 3 次;失败留痕,反复漂移暂停并提示重新审核') token = secrets.token_urlsafe(20) self.plans = {token: {"deadline": time.monotonic() + 90, "report_id": report["report_id"], - "settings_hash": fingerprint(self.store.settings()), "file_hash": file_hash,"timezone_plan":timezone_plan, + "settings_hash": fingerprint(self.store.settings()), "file_hash": file_hash,"timezone_plan":timezone_plan,'needs_timezone_service':needs_timezone_service, "config": {"version": 1, "enabled": False, "interval": interval, "auto_restore": auto_restore, "approved": approved, "watchdog":baseline,"schedule": schedule, "created_at": utc_now()}}} - return {"state": "planned", "plan_id": token, "effects": effects, + return {"state": "planned", "plan_id": token, "effects": effects,'capabilities':self.watchdog.capability_reader(), "reason": "审核后确认启用。开发或试用不会自动替你安装后台任务。"} def enable(self, token, report, confirmed=False): @@ -216,7 +220,7 @@ def enable(self, token, report, confirmed=False): except (OSError,ValueError):pass # Root-owned transaction evidence remains authoritative. return {'state':'partial','reason':'系统时区守护未完成,尚未启用本次巡检。'+result.get('reason',''), 'timezone_service':result,'needs_retest':True} - elif config.get('watchdog',{}).get('timezone') and self.timezone_service and self.timezone_service.system=='Darwin': + elif plan.get('needs_timezone_service') and self.timezone_service: root_status=self.timezone_service.status() if not (root_status.get('installed') and root_status.get('registered') and root_status.get('owner') and root_status.get('target')==config['watchdog']['timezone'] and not root_status.get('pending')): @@ -263,7 +267,7 @@ def disable(self, confirmed=False): self._write("config", config) try: self.scheduler.remove(config.get("schedule")) - note='已单独授权的 macOS 系统时区守护需在下方预览停用;本次不改系统时区。' if self.timezone_service and self.timezone_service.system=='Darwin' else '不恢复系统设置。' + note='已单独授权的系统时区守护需在下方预览停用;本次不改系统时区。' if self.timezone_service else '不恢复系统设置。' result = {"state": "disabled", "reason": "持续维护已停用,用户巡检任务已移除。保留检测记录和私有备份。"+note} except (OSError, ValueError): result = {"state": "partial", "reason": "自动恢复已停用,但系统任务未确认移除。保留任务文件供核对;后台再次唤起也不会执行恢复。"} diff --git a/envscope/network_facts.py b/envscope/network_facts.py index 2f4f6ec..0776e0b 100644 --- a/envscope/network_facts.py +++ b/envscope/network_facts.py @@ -12,6 +12,8 @@ import tempfile import time +from .observation_errors import check_command, error_evidence + HOSTS = ("claude.ai", "api.anthropic.com") @@ -29,7 +31,7 @@ def resolve_host(host, resolver=socket.getaddrinfo): def parse_mac_route(text, destination): values = dict(re.findall(r"^\s*(interface|gateway|destination|mask|flags):\s*([^\n]+)", text, re.M)) if not values.get("interface") or not re.fullmatch(r"[A-Za-z0-9._-]{1,64}", values["interface"].strip()): - return {"destination": destination, "error": "route_unavailable"} + return {'destination': destination, **error_evidence(ValueError(), 'routes')} return {"destination": destination, "interface": values["interface"].strip(), "next_hop": values.get("gateway", "").strip(), "flags": values.get("flags", "").strip()} @@ -50,7 +52,11 @@ def lookup_routes(addresses, system=None, runner=subprocess.run): $source=$best|Where-Object {$_.PSObject.Properties['IPAddress']}|Select-Object -First 1 if(-not $route){throw 'no route'} @{destination=$ip;interface_index=$route.InterfaceIndex;interface=$route.InterfaceAlias;prefix=$route.DestinationPrefix;next_hop=$route.NextHop;source_ip=$source.IPAddress} - } catch { @{destination=$ip;error='route_unavailable'} } + } catch { + $denied=($_.Exception -is [System.UnauthorizedAccessException]) -or ([string]$_.CategoryInfo.Category -eq 'PermissionDenied') + $missing=($_.Exception -is [System.Management.Automation.CommandNotFoundException]) + @{destination=$ip;error=$(if($denied){'permission_denied'}elseif($missing){'tool_missing'}else{'unavailable'})} + } }) ConvertTo-Json -InputObject $items -Depth 4 -Compress """.replace("IP_LIST", json.dumps(addresses)) @@ -59,24 +65,26 @@ def lookup_routes(addresses, system=None, runner=subprocess.run): result = runner(["powershell.exe", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=7, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - value = json.loads(result.stdout) if result.returncode == 0 and len(result.stdout) <= 16384 else None + check_command(result, 16384) + value = json.loads(result.stdout) if not isinstance(value, list): raise ValueError("invalid routes") # Never accept a script response for a destination we did not ask for. - return [{k: r[k] for k in ("destination", "interface_index", "interface", "prefix", "next_hop", "source_ip", "error") if k in r} - for r in value if isinstance(r, dict) and r.get("destination") in addresses] - except (OSError, ValueError, subprocess.TimeoutExpired): - return [{"destination": ip, "error": "route_unavailable"} for ip in addresses] + rows = [r for r in value if isinstance(r, dict) and r.get('destination') in addresses] + if len(rows) != len(addresses) or len({r['destination'] for r in rows}) != len(addresses): + raise ValueError('incomplete routes') + return [{k: r[k] for k in ('destination', 'interface_index', 'interface', 'prefix', 'next_hop', 'source_ip', 'error') if k in r} for r in rows] + except (OSError, ValueError, subprocess.TimeoutExpired) as exc: + return [{'destination': ip, **error_evidence(exc, 'routes')} for ip in addresses] if system == "Darwin": def one(ip): command = ["/sbin/route", "-n", "get", "-inet6" if ":" in ip else "-inet", ip] try: result = runner(command, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3) - if result.returncode == 0 and len(result.stdout) <= 16384: - return parse_mac_route(result.stdout, ip) - except (OSError, subprocess.TimeoutExpired): - pass - return {"destination": ip, "error": "route_unavailable"} + check_command(result, 16384) + return parse_mac_route(result.stdout, ip) + except (OSError, subprocess.TimeoutExpired) as exc: + return {'destination': ip, **error_evidence(exc, 'routes')} with ThreadPoolExecutor(max_workers=4) as pool: return list(pool.map(one, addresses)) return [{"destination": ip, "error": "unsupported_platform"} for ip in addresses] diff --git a/envscope/observation_errors.py b/envscope/observation_errors.py new file mode 100644 index 0000000..0d8962c --- /dev/null +++ b/envscope/observation_errors.py @@ -0,0 +1,71 @@ +"""Bounded failure evidence shared by read-only OS observations. + +Never retain command output: it may contain private paths or credentials. +""" +import errno +import re +import subprocess + +CODES = frozenset({'permission_denied', 'policy_denied', 'timeout', 'tool_missing', + 'invalid_response', 'unavailable'}) + + +class ObservationError(OSError): + def __init__(self, code): + self.code = code if code in CODES else 'unavailable' + super().__init__(self.code) + + +def error_evidence(error, operation): + code = getattr(error, 'code', None) + if code not in CODES: + if getattr(error, 'winerror', None) == 1260: + code = 'policy_denied' + elif isinstance(error, PermissionError) or getattr(error, 'errno', None) in (errno.EACCES, errno.EPERM) or getattr(error, 'winerror', None) == 5: + code = 'permission_denied' + elif isinstance(error, (subprocess.TimeoutExpired, TimeoutError)): + code = 'timeout' + elif isinstance(error, FileNotFoundError): + code = 'tool_missing' + elif isinstance(error, (ValueError, TypeError)): + code = 'invalid_response' + else: + code = 'unavailable' + # Operations are internal identifiers, not commands or user-supplied paths. + name = operation if isinstance(operation, str) and re.fullmatch(r'[a-z_][a-z0-9_.-]{0,63}', operation) else 'observation' + return {'error': code, 'operation': name} + + +def check_command(result, limit=262144): + if result.returncode: + stderr = result.stderr or '' + if isinstance(stderr, bytes): + stderr = stderr.decode('utf-8', errors='replace') + stderr = stderr[:limit].lower() + code = ('policy_denied' if result.returncode == 1260 or 'blocked by group policy' in stderr else + 'permission_denied' if any(value in stderr for value in + ('permission denied', 'operation not permitted', 'access is denied', 'access denied', 'unauthorizedaccessexception')) else + 'unavailable') + raise ObservationError(code) + if not isinstance(result.stdout, str) or len(result.stdout) > limit: + raise ObservationError('invalid_response') + + +def inventory_errors(inventory, *components): + """Normalize old and current collectors, optionally for dependent checks.""" + network = inventory.get('network', {}) + if not isinstance(network, dict): + return {'inventory': error_evidence(ValueError(), 'inventory')} + errors = {} + for name, value in network.get('errors', {}).items() if isinstance(network.get('errors', {}), dict) else (): + code = value.get('error') if isinstance(value, dict) else value + errors[name] = error_evidence(ObservationError(code), name) + for key, name in (('dns_error', 'dns'), ('default_route_error', 'routes')): + if network.get(key): + errors.setdefault(name, error_evidence(ObservationError(network[key]), name)) + if inventory.get('network_error'): + value = inventory['network_error'] + errors['inventory'] = error_evidence(ObservationError(value.get('error') if isinstance(value, dict) else value), 'inventory') + if components: + return {name: value for name, value in errors.items() if name in components or name == 'inventory'} + return errors diff --git a/envscope/operations.py b/envscope/operations.py index 37aad0c..ac3c608 100644 --- a/envscope/operations.py +++ b/envscope/operations.py @@ -151,9 +151,9 @@ def __init__(self, store, home=None, legacy=None, account_manager=None, discover self.remedy_opener = remedy_opener or self._open_remedy self.config_remedies = ConfigRemedies(self.home, self.accounts.data_dir) self.maintenance = maintenance or Maintenance(store, remedies=self.config_remedies, home=self.home) - from .timezone_service import TimezoneService + from .timezone_service import system_timezone_service from .inventory import collect - self.timezone_service = TimezoneService(store,lambda:(self.environment_reader or collect)()) + self.timezone_service = system_timezone_service(store,lambda:(self.environment_reader or collect)()) self.maintenance.timezone_service=self.timezone_service from .process_guard import ProcessGuard self.process_guard = process_guard or ProcessGuard(store, self.home) @@ -604,6 +604,8 @@ def _run(self, action, params, confirmed=False, report=None, preflight=None): desktop = getattr(self, "desktop_runtime", None) return desktop.run(action, params, confirmed) if desktop else {"state": "unavailable", "reason": "此功能需要打开 EnvScope 独立软件。当前是浏览器兼容界面。"} if action == "permission-help": + if report and report.get('environment', {}).get('confirmed') and report['environment'].get('fingerprint') != fingerprint(self.store.settings()): + return {'state': 'retest_required', 'reason': '目标已改变,请先按当前目标重新检测。'} from .permissions import permission_help return permission_help(report, params, self.home, self.accounts.data_dir, confirmed) native_parameters = {"account-label": {"slot", "label"}, "account-switch": {"slot", "allow_force"}, diff --git a/envscope/permissions.py b/envscope/permissions.py index 528d7d7..061ba07 100644 --- a/envscope/permissions.py +++ b/envscope/permissions.py @@ -1,48 +1,109 @@ -"""Scoped help only after an actual OS permission denial, never blanket elevation.""" +"""Scoped OS settings and manual steps after a measured access denial.""" import os from pathlib import Path import platform import subprocess from .assessment import denied +from .observation_errors import error_evidence, inventory_errors + +CAPABILITIES = { + 'network-read': ('读取网卡、DNS 与系统选路', ('system.inventory', 'baseline.dns', 'baseline.tun', 'baseline.ipv6', 'target.tun_route', 'extra.target_route', 'extra.dns_resolution')), + 'process-read': ('读取所选连接工具的运行状态', ('system.inventory', 'baseline.proxy', 'target.wireguard')), + 'region-read': ('读取当前时区与地区格式', ('system.inventory', 'baseline.timezone', 'baseline.locale')), + 'application-read': ('读取 Claude 本地配置与资料目录', ('extra.config', 'target.config', 'extra.profile', 'extra.account', 'extra.storage', 'extra.desktop', 'baseline.language')), + 'code-read': ('读取 Code 用户配置的格式与环境项存在性', ('extra.cli_env', 'target.config', 'extra.config')), + 'hosts-read': ('读取 hosts 中与 Claude 有关的记录', ('extra.hosts',)), + 'proxy-read': ('读取当前用户的系统代理配置', ('extra.proxy_layers', 'target.path')), +} + + +def has_error(value, code): + if isinstance(value, dict): + return value.get('error') == code or any(has_error(v, code) for v in value.values()) + return isinstance(value, list) and any(has_error(v, code) for v in value) + + +def capabilities(finding, usage): + key = finding['id'] + if key == 'system.inventory': + failures = {} + for value in finding.get('evidence', []): + if isinstance(value, dict): + failures.update(inventory_errors(value)) + ids = set() + for name, value in failures.items(): + if denied(value): + ids.add('region-read' if name in ('timezone', 'locale', 'language') else + 'process-read' if name == 'known_clients' else + 'application-read' if name == 'desktop_packages' else 'network-read') + return sorted(ids) or ['network-read'] + if key == 'target.config': + return ['code-read'] if usage == 'cli' else ['application-read', 'code-read'] if usage == 'both' else ['application-read'] + return [name for name, (_, checks) in CAPABILITIES.items() if key in checks] + + +def os_action(capability, system, home, profile): + root = Path(os.environ.get('SystemRoot', 'C:/Windows')) + path = home / '.claude' if capability == 'code-read' else root / 'System32/drivers/etc/hosts' if capability == 'hosts-read' else profile + if capability in ('application-read', 'code-read', 'hosts-read'): + if system == 'Darwin': + if capability == 'hosts-read': + return None, ['系统拒绝读取 /etc/hosts。请让管理员核对该文件的读取权限及组织策略。不要开放写入权限。'] + return ['/usr/bin/open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders'], [ + '在隐私与安全性 → 文件与文件夹中核对 EnvScope 的相关目录权限。', + '若列表没有本软件或仍被拒绝,核对文件所有者与只读权限;完全磁盘访问只在确实需要读取受保护目录时考虑。', + '系统提示重新打开时,请退出再打开 EnvScope 并检测。'] + return [str(root / 'explorer.exe'), '/select,', str(path)], [ + '在所选文件或目录的属性 → 安全中核对当前用户的读取权限。', + '组织管理的权限由管理员处理;无需给整个应用永久管理员权限。'] + if capability == 'process-read': + return ([str(root / 'System32/mmc.exe'), str(root / 'System32/services.msc')] if system == 'Windows' else + ['/usr/bin/open', '/System/Applications/Utilities/Activity Monitor.app']), [ + '核对连接工具或所选隧道服务是否运行,以及其所属用户。', + '进程或服务读取被限制时,请管理员授予所需读取权限;不要改动 VPN 凭据。'] + if capability == 'region-read': + uri = 'ms-settings:dateandtime' if system == 'Windows' else 'x-apple.systempreferences:com.apple.Date-Time-Settings.extension' + steps = ['核对日期与时间以及地区设置。读取被组织策略限制时,请管理员处理。'] + else: + uri = ('ms-settings:network-proxy' if capability == 'proxy-read' else 'ms-settings:network-status') if system == 'Windows' else 'x-apple.systempreferences:com.apple.Network-Settings.extension' + steps = ['核对当前网络及代理配置。系统网络读取没有通用的“允许”开关,若组织策略拒绝读取,请管理员核对对应权限。', + '打开设置不会自行授予权限或修改连接。返回后将重新检测并保留实际结果。'] + return ([str(root / 'explorer.exe'), uri] if system == 'Windows' else ['/usr/bin/open', uri]), steps def permission_help(report, params, home, profile, confirmed=False, runner=subprocess.Popen, system=None): - if set(params) != {"report_id", "finding_id"} or not report or params["report_id"] != report.get("report_id"): - return {"state": "retest_required", "reason": "先完成本轮检测,再处理本次明确拒绝的权限。"} - finding = next((f for f in report.get("findings", []) if f["id"] == params["finding_id"]), None) - if not finding or not denied(finding.get("evidence", [])): - raise ValueError("这项没有明确的权限拒绝证据,不申请额外权限") + if set(params) != {'report_id', 'finding_id'} or not report or params['report_id'] != report.get('report_id'): + return {'state': 'retest_required', 'reason': '先完成本轮检测,再处理本次明确拒绝的权限。'} + finding = next((f for f in report.get('findings', []) if f['id'] == params['finding_id']), None) + if not finding or finding.get('scope') == 'not-selected' or not denied(finding.get('evidence', [])): + raise ValueError('这项没有明确的权限拒绝证据,不申请额外权限') system = system or platform.system() - if finding['id'] == 'target.wireguard': - if system != 'Windows': - return {'state': 'unavailable', 'reason': '此项仅适用于 Windows 隧道服务。'} - reason = '本次明确被拒绝读取 WireGuard 服务或所属进程。将打开 Windows 服务管理器供核对,不修改服务权限、启动方式或隧道;打开入口不等于授权。' - if confirmed is not True: - return {'state': 'confirmation_required', 'reason': reason} - root = Path(os.environ.get('SystemRoot', 'C:/Windows'))/'System32' - runner([str(root/'mmc.exe'), str(root/'services.msc')], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) - return {'state': 'opened', 'reason': '在服务管理器找到所选 WireGuardTunnel 服务,核对所属账户和服务状态。读取权限由管理员或组织策略管理;不要修改 VPN 私钥或绕过组织限制。处理后重新检测。', 'permission_granted': False} - scopes = {"extra.hosts": ("读取 hosts 中与 Claude 有关的记录", Path(os.environ.get("WINDIR", "C:/Windows")) / "System32/drivers/etc/hosts"), - "extra.config": ("读取 Claude / Code 用户配置的格式", profile), - "target.config": ("读取所选 Claude / Code 用户配置的格式", home / '.claude' if report.get('usage') == 'cli' else profile), - "extra.cli_env": ("读取 Code 用户配置的格式与环境项存在性", home / ".claude"), - "extra.proxy_layers": ("读取当前用户的系统代理设置", None)} - if finding["id"] not in scopes: - return {"state": "unavailable", "reason": "系统拒绝了读取。请根据证据检查应用或文件的访问权限,再检测;当前没有可自动打开的授权入口。"} - label, path = scopes[finding["id"]] - reason = label + "。不会修改该配置或导出密钥;将打开系统入口,由你授予必要的读取权限,然后重新检测。" + base = {'scope': 'read', 'permission_granted': False, 'report_id': report['report_id'], + 'finding_id': finding['id'], 'capabilities': [], 'affected_checks': []} + if has_error(finding['evidence'], 'policy_denied'): + return {**base, 'state': 'manual', 'error': 'policy_denied', 'reason': '组织策略拒绝了读取,请联系管理员处理;不会反复请求管理员权限。', + 'manual_steps': ['向设备管理员提供失败的检查名称和 policy_denied 代码,处理后重新检测。']} + if system not in ('Windows', 'Darwin'): + return {**base, 'state': 'unavailable', 'reason': '当前仅支持 Windows 与 macOS。'} + ids = capabilities(finding, report.get('usage')) + entries = [(name, *os_action(name, system, Path(home), Path(profile))) for name in ids] + base['capabilities'] = [{'id': name, 'label': CAPABILITIES[name][0], 'access': 'read', + 'os_action': 'open_settings' if command else 'manual'} for name, command, _ in entries] + base['affected_checks'] = sorted({check for name in ids for check in CAPABILITIES[name][1]}) + steps = list(dict.fromkeys(step for _, _, values in entries for step in values)) + base['manual_steps'] = steps or ['核对该项证据中的读取位置及权限,由资料所有者或设备管理员处理后复检。'] + commands = list(dict.fromkeys(tuple(command) for _, command, _ in entries if command)) + if not commands: + return {**base, 'state': 'manual', 'reason': ' '.join(base['manual_steps'])} if confirmed is not True: - return {"state": "confirmation_required", "reason": reason} - if system == "Darwin": - command = ["/usr/bin/open", "x-apple.systempreferences:com.apple.preference.security?Privacy_FilesAndFolders"] - instruction = "在隐私与安全性中检查 EnvScope 的文件与文件夹权限;仅在受保护资料仍被拒绝时评估完全磁盘访问。授权后重新打开软件并检测。" - elif system == "Windows": - command = [str(Path(os.environ.get("WINDIR", "C:/Windows")) / "explorer.exe")] - command += ["/select,", str(path)] if path else ["ms-settings:network-proxy"] - instruction = "Windows 文件读取权限需在文件或文件夹的属性 → 安全中核对。请让所属用户拥有读取权限;组织策略限制请联系管理员。完成后重新检测。" - else: - return {"state": "unavailable", "reason": "当前仅支持 Windows 与 macOS。"} - runner(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - return {"state": "opened", "reason": instruction, "permission_granted": False} + return {**base, 'state': 'confirmation_required', 'reason': ';'.join(CAPABILITIES[name][0] for name in ids) + + '。将打开对应系统入口供核对,不修改配置或导出凭据。返回后自动复检一次。'} + try: + for command in commands: + runner(list(command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) + except OSError as exc: + return {**base, **error_evidence(exc, 'open_settings'), 'state': 'unavailable', + 'reason': '未能打开系统入口。' + ' '.join(base['manual_steps'])} + return {**base, 'state': 'opened', 'reason': ' '.join(base['manual_steps']), 'recheck_on_return': True} diff --git a/envscope/probes.py b/envscope/probes.py index 65b7fcc..8978120 100644 --- a/envscope/probes.py +++ b/envscope/probes.py @@ -8,6 +8,7 @@ from .browser import assess_snapshot, environment_finding from .controller import inspect_controller from .inventory import collect +from .observation_errors import inventory_errors from .model import Finding, Status, utc_now from .network_facts import bounded_facts from .local_facts import local_snapshot @@ -87,9 +88,11 @@ def audit(policy, transport, online=False, stun=False, ipv6=False, targets=(), b network = facts_job.result() if facts_job else {"dns": [], "routes": []} v4, v6 = v4_job.result() if v4_job else [], v6_job.result() if v6_job else [] udp = (udp4_job.result() if udp4_job else []) + (udp6_job.result() if udp6_job else []) + incomplete = inventory_errors(inventory) findings = [ - Finding("system.inventory", "系统只读信息采集", Status.UNKNOWN if "network_error" in inventory or inventory.get("network", {}).get("errors") else Status.PASS, - "系统信息已采集;虚拟网卡和进程存在均不是接管证明", "native-inventory", [inventory]), + Finding("system.inventory", "系统只读信息采集", Status.UNKNOWN if incomplete else Status.PASS, + '部分系统信息读取失败;请查看对应原因' if incomplete else '系统信息已采集;虚拟网卡和进程存在均不是接管证明', + 'native-inventory', [inventory, {'collection_errors': incomplete}] if incomplete else [inventory]), Finding("network.route", "目标流量接管验证", Status.UNKNOWN, "当前只记录路由配置,尚未完成目标进程流量关联", "native-inventory", limitations=["默认路由、TUN 名称或 Fake-IP 不能单独证明目标流量没有旁路。"]), ] diff --git a/envscope/region_setup.py b/envscope/region_setup.py index 5e63878..0a0c91d 100644 --- a/envscope/region_setup.py +++ b/envscope/region_setup.py @@ -48,7 +48,7 @@ def set_system_value(system, key, value, runner=_run): if result.returncode == 0: return # Only tzutil is elevated; user culture must remain in the original user's session. - script = "$ErrorActionPreference='Stop'; $p=Start-Process -FilePath '" + executable.replace("'", "''") + "' -ArgumentList '/s \"" + value + "\"' -Verb RunAs -WindowStyle Hidden -PassThru -Wait; exit $p.ExitCode" + script = "$ErrorActionPreference='Stop'; try{$p=Start-Process -FilePath '" + executable.replace("'", "''") + "' -ArgumentList '/s \"" + value + "\"' -Verb RunAs -WindowStyle Hidden -PassThru -Wait; exit $p.ExitCode}catch{$e=$_.Exception;while($e){if($e -is [ComponentModel.Win32Exception]){exit $e.NativeErrorCode};$e=$e.InnerException};exit 1}" else: script = "$ErrorActionPreference='Stop'; Set-Culture -CultureInfo '" + value.replace('_', '-') + "'" encoded = base64.b64encode(script.encode('utf-16-le')).decode('ascii') @@ -62,7 +62,8 @@ def set_system_value(system, key, value, runner=_run): else: raise ValueError('此系统不支持自动配置') if result.returncode != 0: - raise ValueError('系统未允许或未完成修改;请在日期与时间 / 语言与地区设置中手动处理后复检') + from .consent import raise_for_consent + raise_for_consent(result, system) class NodeController: @@ -193,7 +194,7 @@ def prepare(self, report, components, group='', node=''): changes[key] = selected[key] effects.append(label + ':' + before[key] + ' → ' + selected[key]) timezone_plan=None - if 'timezone' in components and self.system=='Darwin' and self.timezone_service: + if 'timezone' in components and self.timezone_service: status=self.timezone_service.status() if status.get('pending') or status.get('state')=='conflict' or status.get('state')=='attention' and 'installed' not in status: return {'state':'blocked','reason':status.get('reason','先处理系统时区守护状态。')} @@ -265,8 +266,10 @@ def apply(self, token, report, confirmed): receipt['applied'].append(key) self._save_recovery(receipt) steps.append({'item': key, 'state': 'verified', 'reason': '配置已读回;网络出口另行复检'}) - except (ValueError, OSError, subprocess.TimeoutExpired): - steps.append({'item': key, 'state': 'manual', 'reason': '未确认修改完成,后续步骤已停止。按恢复记录核对实际值;权限取消、超时或自动时区覆盖时请在系统 / 代理客户端中处理。'}) + except (ValueError, OSError, subprocess.TimeoutExpired) as exc: + from .consent import operation_outcome, MESSAGES + outcome = operation_outcome(exc) + steps.append({'item': key, 'state': 'manual', **outcome, 'reason': MESSAGES[outcome['error']] + '后续步骤已停止,请按恢复记录核对实际值。'}) break receipt['state'] = 'completed' if len(receipt['applied']) == len(plan['changes']) else 'partial' self._save_recovery(receipt) diff --git a/envscope/system_network.py b/envscope/system_network.py index fdf33a7..33230c4 100644 --- a/envscope/system_network.py +++ b/envscope/system_network.py @@ -172,8 +172,9 @@ def apply(self,token,confirmed=False): and (not plan['payload']['flush_dns'] or status.get('dns_flush')=='completed')) except (OSError,ValueError,subprocess.SubprocessError) as error: recovery=self.status() + from .consent import operation_outcome return {'state':'partial','reason':str(error)[:240]+' 请查看恢复状态;原备份没有自动丢弃。', - 'status':recovery,'needs_retest':True} + 'status':recovery,**operation_outcome(error)} return {'state':'completed' if verified else 'partial','status':public_status(status),'needs_retest':True, 'transaction_id':plan['payload']['nonce'],'scope':'selected-network-settings', 'reason':'所列网络设置已读回;现在复检解析、连接及出口。' if verified else '系统操作返回,但配置或缓存刷新尚未完整确认;请按恢复记录处理。'} @@ -214,6 +215,7 @@ def restore(self,token,confirmed=False): restored=(status.get('state')=='restored' and all(r['id'] in rows and all(rows[r['id']][k]==r[k] for k in ('ipv6_enabled','config')) for r in plan['record'].get('adapters',[]))) except (OSError,ValueError,subprocess.SubprocessError) as error: - return {'state':'partial','reason':str(error)[:240]+' 恢复记录保留。','needs_retest':True} + from .consent import operation_outcome + return {'state':'partial','reason':str(error)[:240]+' 恢复记录保留。','status':self.status(),**operation_outcome(error)} return {'state':'completed' if restored else 'partial','status':public_status(status),'needs_retest':True, 'reason':'系统网络恢复已返回,请复检实际设置与连接。'} diff --git a/envscope/system_network_macos.py b/envscope/system_network_macos.py index 5379c85..553a9a2 100644 --- a/envscope/system_network_macos.py +++ b/envscope/system_network_macos.py @@ -82,5 +82,5 @@ def execute(self,action,payload): script=self.script(action,path,hashlib.sha256(data).hexdigest(),self.worker()) apple='do shell script '+json.dumps(script,ensure_ascii=False)+' with administrator privileges without altering line endings' result=self.runner(['/usr/bin/osascript','-e',apple],capture_output=True,timeout=150) - if result.returncode: - raise ValueError('系统授权取消或网络修改未完成。请刷新恢复记录;未将命令返回当作修复成功。') + from .consent import raise_for_consent + raise_for_consent(result, 'Darwin') diff --git a/envscope/system_network_windows.py b/envscope/system_network_windows.py index df43924..74d5982 100644 --- a/envscope/system_network_windows.py +++ b/envscope/system_network_windows.py @@ -273,14 +273,10 @@ def execute(self,action,payload): "$options=@{FilePath="+quote(self.executable)+";ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$code);WindowStyle='Hidden';Wait=$true;PassThru=$true};" "$p=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent());" "if(-not $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){$options.Verb='RunAs'};" - "$process=Start-Process @options;exit $process.ExitCode") + "try{$process=Start-Process @options;exit $process.ExitCode}catch{" + "$e=$_.Exception;while($e){if($e -is [ComponentModel.Win32Exception]){exit $e.NativeErrorCode};$e=$e.InnerException};exit 1}") result=self.runner([self.executable,'-NoProfile','-NonInteractive','-Command',launcher],input=encoded.encode(), capture_output=True,timeout=150,creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) if result.returncode: - detail='' - try: - failure=self._read('failure');code=failure.get('code','') - if failure.get('data_sha')==hashlib.sha256(data).hexdigest() and isinstance(code,str) and len(code)<=160: - detail=' 记录代码:'+code - except (OSError,ValueError):pass - raise ValueError('系统授权取消或网络修改未确认完成,请刷新恢复记录;没有按命令返回判定修复成功。'+detail) + from .consent import raise_for_consent + raise_for_consent(result, 'Windows') diff --git a/envscope/target_environment.py b/envscope/target_environment.py index 877eb7a..56d1078 100644 --- a/envscope/target_environment.py +++ b/envscope/target_environment.py @@ -250,7 +250,9 @@ def add(key, title, status, message, data, fix, limits=()): '在系统代理设置中按目标连接方式调整后复检。TUN 另查隧道状态;PAC、浏览器扩展及进程流量需在实际应用确认。', ['探测路径和配置一致不证明 Claude 进程全流量经过同一路径。']) if mode == 'tun': + from .observation_errors import inventory_errors network = first('system.inventory').get('network', {}) + collection_errors = inventory_errors(first('system.inventory'), 'adapters', 'routes') adapters = network.get('adapters', []) wg_state, wg_row = None, None if settings.get('client') == 'wireguard': @@ -284,8 +286,9 @@ def add(key, title, status, message, data, fix, limits=()): or re.search(r'REJECT|BLACKHOLE', r.get('flags', ''), re.I)] if wg_state == 'healthy' else [] route_ok = dns_ok and bool(addresses) and addresses <= {r.get('destination') for r in measured} and not mismatched route_ok = route_ok and (wg_state == 'healthy' if settings.get('client') == 'wireguard' else True) - add('tun_route', '目标地址的 TUN 选路', 'fail' if mismatched else 'pass' if route_ok else 'unknown', - '所检查的目标地址经系统选择隧道接口' if route_ok else '目标地址存在非隧道选路' if mismatched else '目标选路证据不完整', routes, + route_ok = route_ok and not collection_errors + add('tun_route', '目标地址的 TUN 选路', 'unknown' if collection_errors else 'fail' if mismatched else 'pass' if route_ok else 'unknown', + '目标选路证据不完整' if collection_errors else '所检查的目标地址经系统选择隧道接口' if route_ok else '目标地址存在非隧道选路' if mismatched else '目标选路证据不完整', routes + ([{'collection_errors': collection_errors}] if collection_errors else []), '在所选 WireGuard 隧道核对 AllowedIPs、隧道激活状态与 IPv6 路由,再复检;不会在这里改动私钥。' if settings.get('client') == 'wireguard' else '在代理客户端核对 TUN、自动路由和 IPv6 规则,再重新检测。', ['每个目标及地址族抽样一个解析地址;不等于进程全流量证明。']) clock = raw.get('extra.clock', {}) add('clock', '系统时钟准确性', 'fail' if clock.get('status') == 'warn' else clock.get('status', 'unknown'), clock.get('summary', '尚未取得多个可信连接的日期样本'), diff --git a/envscope/timezone_heartbeat.py b/envscope/timezone_heartbeat.py new file mode 100644 index 0000000..60d22cf --- /dev/null +++ b/envscope/timezone_heartbeat.py @@ -0,0 +1,29 @@ +"""One strict identity/freshness check for native service and watchdog views.""" +from . import __release__ + + +def normalize_heartbeat(value, system): + if system != 'Windows': + return value + result = dict(value) + for key in ('format', 'guard_policy'): + text = value.get(key) + result[key] = int(text) if isinstance(text, str) and text in ('1', '2') else None + for key in ('fixture', 'initial'): + result[key] = {'0': False, '1': True}.get(value.get(key)) + try: + result['at_epoch'] = float(value.get('at_epoch', 0)) + except (TypeError, ValueError): + result['at_epoch'] = 0 + return result + + +def heartbeat_current(baseline, heartbeat, now, *, fixture=False): + stamp = heartbeat.get('at_epoch') + return (type(heartbeat.get('format')) is int and heartbeat['format'] == 1 + and type(heartbeat.get('guard_policy')) is int and heartbeat['guard_policy'] == 2 + and baseline.get('release') == __release__ + and heartbeat.get('release') == baseline.get('release') + and bool(baseline.get('target')) and heartbeat.get('target') == baseline['target'] + and heartbeat.get('fixture') is fixture and heartbeat.get('initial') is False + and type(stamp) in (int, float) and 0 <= now - stamp < 180) diff --git a/envscope/timezone_service.py b/envscope/timezone_service.py index 9ba909d..791ef56 100644 --- a/envscope/timezone_service.py +++ b/envscope/timezone_service.py @@ -28,6 +28,7 @@ from .region_setup import current_region from .remedies import SolutionPlans from .target_environment import fingerprint +from .timezone_heartbeat import heartbeat_current @dataclass(frozen=True) @@ -64,22 +65,22 @@ def daemon_bytes(layout): 'StandardOutPath':'/dev/null','StandardErrorPath':'/dev/null'},sort_keys=True) -def bundle_worker(root=None,cache=None,*,component='timezone'): +def bundle_worker(root=None,cache=None,*,component='timezone',system='Darwin'): if component not in ('timezone','network','components'):raise ValueError('不支持的系统辅助程序') - binary=component+'-helper';label={'timezone':'时区','network':'网络','components':'组件迁移'}[component] + name=component+'-helper';binary=name+('.exe' if system=='Windows' else '');label={'timezone':'时区','network':'网络','components':'组件迁移'}[component] root=Path(root) if root is not None else Path(__file__).resolve().parent/'native-runtime' try: - metadata=root/(binary+'.json') + metadata=root/(name+'.json') if metadata.stat().st_size>65536:raise ValueError('oversized helper metadata') manifest=json.loads(metadata.read_text(encoding='utf-8')) - payload=root/(binary+'.payload.zip') + payload=root/(name+'.payload.zip') if payload.stat().st_size>2*1024**2:raise ValueError('oversized helper payload') with zipfile.ZipFile(payload) as archive: if archive.namelist()!=[binary] or not 0131072: + raise ValueError('无法核实系统时区任务权限、归属或记录。') + return json.loads(value.stdout) + + def inspect(self): + value=self._read('$value=Snapshot;$value.current_owner=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;$value | ConvertTo-Json -Depth 12 -Compress') + self._owner=value.pop('current_owner');return value + def owner(self): + if not hasattr(self,'_owner'):self.inspect() + return self._owner + def authority(self): + value=self.inspect();beat=value.get('status',{}) + return {**value,'registered':value['registered'] and value['enabled'],'foreign_owner':bool(value['baseline']) and value['baseline'].get('owner')!=self.owner(), + 'fixture':self.fixture,'status':normalize_heartbeat(beat,'Windows')} + + def execute(self,payload): + data=json.dumps(payload,sort_keys=True).encode() + with tempfile.TemporaryDirectory(prefix='envscope-timezone-') as folder: + path=Path(folder).resolve()/'plan.json';path.write_bytes(data) + code=self.script(TRANSACTION.replace('@INPUT@',quote(path)).replace('@SHA@',quote(hashlib.sha256(data).hexdigest()))) + # Keep the reviewed source readable. The bounded elevated entry point + # hashes one in-memory snapshot, then runs precisely those bytes. + # No mutable script path is retained by the SYSTEM task. + source=Path(folder).resolve()/'installer.ps1';source.write_bytes(code.encode('utf-8')) + bootstrap=installer_bootstrap(source,hashlib.sha256(source.read_bytes()).hexdigest()) + encoded=base64.b64encode(bootstrap.encode('utf-16-le')).decode() + command=[self.executable,'-NoProfile','-NonInteractive','-EncodedCommand',encoded] + if len(subprocess.list2cmdline(command).encode('utf-16-le'))//2>32600:raise ValueError('时区操作超出命令长度限制,未执行。') + launcher=("$ErrorActionPreference='Stop';$code=[Console]::In.ReadToEnd();if($code -cnotmatch '^[A-Za-z0-9+/=]+$' -or $code.Length -gt 32500){exit 65};" + "$o=@{FilePath="+quote(self.executable)+";ArgumentList=@('-NoProfile','-NonInteractive','-EncodedCommand',$code);WindowStyle='Hidden';Wait=$true;PassThru=$true};" + "$p=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent());if(-not $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)){$o.Verb='RunAs'};" + "try{$v=Start-Process @o;exit $v.ExitCode}catch{$e=$_.Exception;while($e){if($e -is [ComponentModel.Win32Exception]){exit $e.NativeErrorCode};$e=$e.InnerException};exit 1}") + result=self.runner([self.executable,'-NoProfile','-NonInteractive','-Command',launcher],input=encoded.encode(),capture_output=True,timeout=150,creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) + raise_for_consent(result,'Windows') + + +def installer_bootstrap(source, expected): + """Bind a foreground consent request to readable, byte-exact source. + + Loading the snapshot once avoids a verify/execute path race. OS application + control and PowerShell language restrictions still apply; no policy changes + or execution-policy switches are made here. + """ + if not re.fullmatch(r'[a-f0-9]{64}',expected):raise ValueError('Invalid source digest') + return ("$ErrorActionPreference='Stop';$path="+quote(source)+";" + "if((Get-Item -LiteralPath $path).Length -gt 196608){throw 'oversized installer'};" + "$bytes=[IO.File]::ReadAllBytes($path);$hash=[Security.Cryptography.SHA256]::Create();" + "try{$actual=([BitConverter]::ToString($hash.ComputeHash($bytes))).Replace('-','').ToLowerInvariant()}finally{$hash.Dispose()};" + "if($actual -cne '"+expected+"'){throw 'reviewed installer changed'};" + "$source=[Text.UTF8Encoding]::new($false,$true).GetString($bytes);" + "& ([scriptblock]::Create($source))") + + +def stable(value): + return {key:value.get(key) for key in ('baseline_sha','worker_sha','registered','enabled')} | {'transaction':{'id':value.get('transaction',{}).get('id')}} + + +class WindowsTimezoneService: + system='Windows' + def __init__(self,store,reader,*,io=None,**kwargs): + self.store,self.reader=store,reader;self.io=io or WindowsTimezoneIO();self.plans={} + + def status(self): + try: + snapshot=self.io.inspect();base=snapshot['baseline'];beat=snapshot['status'];receipt=snapshot.get('transaction',{}) + owner=base.get('owner')==self.io.owner() if base and not snapshot['pending'] else receipt.get('owner')==self.io.owner() + normalized=normalize_heartbeat(beat,'Windows');stamp=normalized.get('at_epoch',0) + current=heartbeat_current(base,normalized,self.io.clock(),fixture=self.io.fixture) + state=('recovery_required' if snapshot['pending'] else 'conflict' if base and not owner else 'not_installed' if not base else + 'unregistered' if not snapshot['registered'] or not snapshot['enabled'] else 'waiting' if not current else + 'active' if beat.get('state') in ('healthy','restored') and beat.get('observed')==base.get('target') else 'attention') + reasons={'recovery_required':'上次系统时区操作未完成,请先预览恢复。','conflict':'时区任务属于另一用户,未覆盖其基线。', + 'not_installed':'尚未授权独立的系统时区任务。','unregistered':'时区任务未注册或被停用,请重新预览。', + 'waiting':'等待当前基线的有效心跳,尚未计为自动守护正常。','active':'系统时区基线已读回,每 60 秒检查;后台不弹授权窗口。', + 'attention':'系统时区保持需要处理,请查看记录后重新审核。'} + return {'supported':True,'state':state,'reason':reasons[state],'installed':bool(base),'registered':snapshot['registered'] and snapshot['enabled'], + 'owner':owner,'target':base.get('target'),'original':base.get('original'),'observed':beat.get('observed'),'heartbeat_at':stamp, + 'pending':snapshot['pending'],'needs_update':bool(base) and base.get('release')!=__release__, + 'can_undo':owner and receipt.get('state')=='committed','release':base.get('release'), + 'intentionally_removed':not base and owner and (receipt.get('state')=='restored' or receipt.get('state')=='committed' and receipt.get('action')=='remove'), + 'journal_path':'%ProgramData%/EnvScope/TimezoneGuard','capability':'protected-native-task'} + except (OSError,ValueError,TypeError,KeyError,subprocess.SubprocessError): + return {'supported':True,'state':'attention','reason':'系统时区任务权限或归属未能核实,未报告守护正常。'} + + def prepare(self,report,*,remove=False,restore=False): + if type(remove) is not bool or type(restore) is not bool:raise ValueError('时区选项无效') + from .remedies import SolutionPlans + from .regions import region_finding,PARTIAL + from .model import Status + settings=self.store.settings() + if not remove and (not SolutionPlans._current(report) or not settings.get('environment_confirmed') or report.get('environment',{}).get('fingerprint')!=fingerprint(settings)): + return {'state':'retest_required','reason':'先按当前目标完成新的检测,再授权时区保持。'} + if not remove and (not settings.get('usage_country') or settings['usage_country'] in PARTIAL or region_finding([],settings).status==Status.FAIL or not any(f['id']=='target.region' and f['status']=='pass' for f in report.get('findings',[]))): + return {'state':'blocked','reason':'官方地区条件未核实通过,未生成环境修改。'} + snapshot=self.io.inspect();base=snapshot['baseline'];owner=self.io.owner() + if snapshot['pending']:return {'state':'recovery_required','reason':'请先恢复上次未完成的时区事务。'} + if base and base.get('owner')!=owner:return {'state':'blocked','reason':'时区基线属于另一用户。'} + if remove and not base:return {'state':'guided','reason':'没有已安装的系统时区保持任务。'} + observed=current_region(self.reader())['timezone'];target=base.get('target') if remove else settings.get('timezone') + from .region_profiles import profiles + if target not in {p['timezone'] for p in profiles('Windows')} or not re.fullmatch('[A-Za-z0-9 +_-]{1,100}',observed): + return {'state':'guided','reason':'请先选择可核对的城市与系统时区。'} + worker=self.io.worker() if not remove else None + payload={'nonce':secrets.token_hex(16),'owner':owner,'action':'remove' if remove else 'install','before':stable(snapshot), + 'observed':observed,'target':target,'restore':restore,'worker':worker,'release':__release__} + return self._plan(payload, ['申请一次 Windows 系统授权;仅运行受保护的原生时区任务,影响整台电脑。', + ('停用时区任务;'+('恢复安装前时区 '+base['original'] if restore else '保留当前时区。')) if remove else + '保持 '+target+';每 60 秒检查,每次修复前留痕,至少间隔 5 分钟、24 小时最多恢复 3 次。', + '失败或中断时暂停自动写入并保留日志、备份;主应用和账号操作继续以普通用户运行。']) + + def _plan(self,payload,effects): + token=secrets.token_urlsafe(20);signature=hashlib.sha256(json.dumps(payload['before'],sort_keys=True).encode()).hexdigest() + self.plans={token:{'payload':payload,'deadline':time.monotonic()+90,'fingerprint':fingerprint(self.store.settings())}} + return {'state':'planned','plan_id':token,'effects':effects,'transaction_nonce':payload.get('restore_id',payload['nonce']), + 'before_signature':signature,'reason':'核对后确认;Windows 仅在实际系统操作时申请授权。'} + + def apply(self,token,confirmed=False): + if confirmed is not True:return {'state':'confirmation_required'} + plan=self.plans.get(token) if isinstance(token,str) else None + if not plan or time.monotonic()>plan['deadline'] or plan['fingerprint']!=fingerprint(self.store.settings()):return {'state':'retest_required','reason':'方案已过期或目标改变,请重新预览。'} + payload=plan['payload'];snapshot=self.io.inspect() + if stable(snapshot)!=payload['before'] or (payload['action']=='install' and self.io.worker()!=payload['worker']):return {'state':'retest_required','reason':'服务或辅助程序已改变,请重新预览。'} + del self.plans[token] + try:self.io.execute(payload) + except (OSError,ValueError,subprocess.SubprocessError) as exc: + return {'state':'partial','reason':str(exc)[:240],'status':self.status(),**operation_outcome(exc),'transaction_nonce':payload.get('restore_id',payload['nonce'])} + after=self.io.inspect();r=after.get('transaction',{});nonce=payload.get('restore_id',payload['nonce']) + good=r.get('id')==nonce and r.get('state')==('restored' if payload['action']=='restore' else 'committed') and not after['pending'] + if payload['action']=='install':good=good and after['registered'] and after['enabled'] and after['baseline'].get('target')==payload['target'] and current_region(self.reader())['timezone']==payload['target'] + elif payload['action']=='remove':good=good and not after['registered'] and not after['baseline'] + return {'state':'completed' if good else 'partial','status':self.status(),'needs_retest':True,'transaction_nonce':nonce, + 'reason':'系统时区操作已读回,请复检并确认后台心跳。' if good else '实际状态未完整读回,请保留记录并恢复。'} + + def _restore_plan(self,*,pending=False,nonce=None): + snapshot=self.io.inspect();record=snapshot.get('transaction',{});owner=self.io.owner() + if record.get('owner')!=owner:return {'state':'blocked','reason':'恢复记录不属于当前用户。'} + if nonce and record.get('id')!=nonce:return {'state':'retest_required','reason':'已有后续时区操作,未覆盖。'} + if (pending and not snapshot['pending']) or record.get('state') not in ('committed','partial','pending','restored') or record.get('state')=='restored' and not snapshot['pending']:return {'state':'guided','reason':'没有符合条件的恢复记录。'} + if record.get('state')=='committed' and not snapshot['pending'] and (record.get('after_baseline_sha')!=snapshot['baseline_sha'] or record.get('after_worker_sha')!=snapshot['worker_sha']): + return {'state':'blocked','reason':'事务完成后的系统基线或程序又有变化,未覆盖后续修改。'} + payload={'nonce':secrets.token_hex(16),'owner':owner,'action':'restore','before':stable(snapshot),'restore_id':record['id']} + return self._plan(payload,['申请系统授权,停止当前时区任务,并恢复最新事务保存的时区、辅助程序与后台任务状态。','保留完整备份和日志;恢复后重新检测。']) + + def prepare_recovery(self):return self._restore_plan(pending=True) + def prepare_undo(self,nonce=None):return self._restore_plan(nonce=nonce) + def recover(self,token,confirmed=False):return self.apply(token,confirmed) + def undo(self,token,confirmed=False):return self.apply(token,confirmed) + def rollback_latest(self,nonce,confirmed=False,*,unchanged_signature=None): + if confirmed is not True:return {'state':'confirmation_required'} + snapshot=self.io.inspect() + if snapshot.get('transaction',{}).get('id')!=nonce and unchanged_signature==hashlib.sha256(json.dumps(stable(snapshot),sort_keys=True).encode()).hexdigest():return {'state':'completed','reason':'时区任务尚未变化。'} + plan=self._restore_plan(nonce=nonce) + return self.apply(plan['plan_id'],True) if plan.get('state')=='planned' else plan diff --git a/envscope/updates.py b/envscope/updates.py index 8cdeceb..b2c5263 100644 --- a/envscope/updates.py +++ b/envscope/updates.py @@ -17,6 +17,7 @@ from . import __version__, __release__ from .legacy import UPSTREAM_COMMIT +from .app_identity import verify_package_identity REPOSITORY = "qudadd/envscope" RELEASE = __release__ @@ -266,6 +267,7 @@ def install(self, tag, confirmed=False): except (zipfile.BadZipFile, UnicodeError, RuntimeError) as error: raise ValueError("更新包不能安全展开,旧版本保持可用") from error relative = exe.relative_to(staging) + identity=verify_package_identity(exe,self.system,tag) digest = sha256(exe) archive.unlink() os.replace(staging, destination) @@ -273,9 +275,9 @@ def install(self, tag, confirmed=False): previous = self.store._read("desktop-install.private.json", {}) if not isinstance(previous, dict): previous = {} - receipt = {"version": tag, "executable": str((destination / relative).relative_to(self.store.root)), "sha256": digest} + receipt = {"version": tag, "executable": str((destination / relative).relative_to(self.store.root)), "sha256": digest,'identity':identity} self.store._write("desktop-install.private.json", {"active": receipt, "previous": previous.get("active")}) - self.state = {**self.state, "state": "ready", "reason": "新版已核验并安装。可以重启 EnvScope;账号、网络与检测偏好没有修改。"} + self.state = {**self.state, "state": "ready",'identity':identity, "reason": "新版应用身份、版本与摘要已核验。此为未正式签名的开源构建;重启后使用新版,旧版和用户资料保留。"} return self.status() finally: if staging and staging.resolve().is_relative_to((self.store.root / "updates/versions").resolve()) and staging.name.startswith(".stage-"): @@ -295,6 +297,8 @@ def installed(self, previous=False): raise ValueError("已安装版本的路径无效") if sha256(path) != receipt.get("sha256"): raise ValueError("已安装的主程序发生变化,请重新下载") + if receipt.get('identity') is not None and verify_package_identity(path,self.system,receipt['version'])!=receipt['identity']: + raise ValueError('已安装版本的应用身份记录发生变化,请重新下载。') return path def rollback(self, confirmed=False): diff --git a/envscope/watchdog.py b/envscope/watchdog.py index e189070..68af07a 100644 --- a/envscope/watchdog.py +++ b/envscope/watchdog.py @@ -16,6 +16,7 @@ from .region_setup import current_region, discover_controller, set_system_value, _run from .accounts import _safe_chain from .transport import CurlTransport +from .timezone_heartbeat import heartbeat_current def native_write(key, value, system=None, runner=_run): @@ -28,7 +29,7 @@ def native_write(key, value, system=None, runner=_run): command=([str(Path(os.environ.get('SystemRoot','C:/Windows'))/'System32/tzutil.exe'),'/s',value] if system=='Windows' else ['/usr/sbin/systemsetup','-settimezone',value]) if system not in ('Windows','Darwin') or runner(command,timeout=15).returncode: - raise ValueError('后台没有修改时区的权限;请在持续维护页授权 macOS 系统时区守护,或在系统设置处理') + raise ValueError('后台没有修改时区的权限;请在守护方案中授权系统时区守护,或在系统设置处理') else: set_system_value(system,key,value,runner) @@ -86,19 +87,28 @@ def launch_proxy(client,system=None): class Watchdog: - def __init__(self,home=None,system=None,reader=None,writer=None,controller=None,clients=None,launcher=None,listener=None,tester=None,sleeper=None,authority=None): + def __init__(self,home=None,system=None,reader=None,writer=None,controller=None,clients=None,launcher=None,listener=None,tester=None,sleeper=None,authority=None,capability_reader=None): self.home=Path(home or Path.home());self.system=system or platform.system() self.reader=reader or collect;self.writer=writer or (lambda k,v:native_write(k,v,self.system)) self.controller=controller or (lambda:discover_controller(self.home,self.system)) self.clients=clients or (lambda:proxy_apps(self.home,self.system));self.launcher=launcher or (lambda c:launch_proxy(c,self.system)) self.listener=listener or proxy_alive;self.tester=tester or proxy_works;self.sleep=sleeper or time.sleep self.authority=authority + from .guard_capabilities import write_capabilities + self.capability_reader=capability_reader or (lambda:{'timezone':{'state':'available','source':'injected-writer'}} if writer else write_capabilities(self.system)) if authority is None and self.system=='Darwin' and platform.system()=='Darwin' and reader is None and writer is None: from .timezone_service import NativeTimezoneIO - self.authority=NativeTimezoneIO().inspect + native_io=NativeTimezoneIO() + def native_authority(): + value=native_io.inspect();base=value.get('baseline',{}) + return {**value,'fixture':native_io.fixture,'foreign_owner':bool(base) and base.get('owner_uid')!=os.getuid()} + self.authority=native_authority + if authority is None and self.system=='Windows' and platform.system()=='Windows' and reader is None and writer is None: + from .timezone_windows import WindowsTimezoneIO + self.authority=WindowsTimezoneIO().authority def options(self): - return {'clients':self.clients(),'note':'仅重开已安装并明确选择的客户端;不安装、强杀进程或回退直连。macOS 时区可在本页单独授权系统守护;用户任务不会弹出管理员授权。'} + return {'clients':self.clients(),'capabilities':self.capability_reader(),'note':'仅重开已安装并明确选择的客户端;不安装、强杀进程或回退直连。时区保持按需单独授权系统守护;后台不会弹出管理员授权。'} def validate(self,baseline): from .region_profiles import profiles @@ -115,7 +125,7 @@ def validate(self,baseline): or not re.fullmatch('[a-f0-9]{64}',client['sha256'])):raise ValueError('批准的客户端基线无效') if any(k in value for k in ('group','node','identity')) and not all(isinstance(value.get(k),str) and 0now for t in attempts):raise ValueError('守护时间记录异常,请核对系统时钟') attempts=[t for t in attempts if now-t<86400] diff --git a/envscope/web/app-icon.png b/envscope/web/app-icon.png new file mode 100644 index 0000000..4ef8ed7 Binary files /dev/null and b/envscope/web/app-icon.png differ diff --git a/envscope/web/app.icns b/envscope/web/app.icns new file mode 100644 index 0000000..8b6d9e2 Binary files /dev/null and b/envscope/web/app.icns differ diff --git a/envscope/web/app.ico b/envscope/web/app.ico new file mode 100644 index 0000000..ccf339a Binary files /dev/null and b/envscope/web/app.ico differ diff --git a/envscope/web/app.mjs b/envscope/web/app.mjs index 33b2c06..9ce71c6 100644 --- a/envscope/web/app.mjs +++ b/envscope/web/app.mjs @@ -8,10 +8,16 @@ import {initConnection, disconnected} from "./connection.mjs"; import {initTargetEnvironment} from './target.mjs'; import {initWatchdogForms} from './watchdog_ui.mjs'; import {initCoverage, browserEnvironment} from './coverage.mjs'; +import {initPermissionFlow} from './permission_flow.mjs'; +import {mountIcons} from './icons.mjs'; +import {initWorkspace} from './workspace_ui.mjs'; + +mountIcons(); +initWorkspace(); const byId=id=>document.getElementById(id); const nodes={start:byId("start"),share:byId("share"),checks:byId("checks"),quit:byId("quit")}; -let lastSnapshot=null,lastReport=null; +let lastSnapshot=null,lastReport=null,lastSource='current',selectionRevision=0; const sessionPromise=(async()=>{ const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),1500); try { @@ -27,6 +33,10 @@ const sessionPromise=(async()=>{ })(); initConnection(sessionPromise); initCoverage(); +document.addEventListener('envscope-open-guard',()=>{document.querySelector('[data-page="upkeep"]').click();byId('guard-alert').hidden=true;}); +document.addEventListener('envscope-guard-attention',event=>{byId('guard-alert').hidden=false;byId('guard-alert-message').textContent=event.detail.message;}); +document.addEventListener('envscope-guard-clear',()=>{byId('guard-alert').hidden=true;}); +byId('guard-alert-open').addEventListener('click',()=>document.dispatchEvent(new CustomEvent('envscope-open-guard'))); const environment={timezone:Intl.DateTimeFormat().resolvedOptions().timeZone||"unknown",languages:[...navigator.languages], platform:navigator.userAgentData?.platform||navigator.platform||"unknown",automated:navigator.webdriver===true}; const envRows=[["时区",environment.timezone],["语言",environment.languages.join(" · ")],["运行环境",environment.platform+(environment.automated?" / 自动化会话":"")]]; @@ -83,6 +93,7 @@ function render(snapshot) { } function renderDesktop(report,source='current') { + lastSource=source; byId("checks-title").hidden=false; diagnosisUI.render(report); guideUI.render(report); @@ -95,6 +106,10 @@ initWatchdogForms(diagnosisUI.run); initInspection({run:diagnosisUI.run,operate:diagnosisUI.operate,isBusy:diagnosisUI.isBusy,retest:checks=>runAudit({checks})}); document.addEventListener('envscope-target-changed',()=>{lastReport=null;lastSnapshot=null;nodes.share.disabled=true;byId('qualification').hidden=true;byId('verdict').textContent='目标已更新,等待本轮检测';}); initDesktopApp({run:diagnosisUI.run,operate:diagnosisUI.operate}); +document.addEventListener('envscope-selection-changed',()=>{selectionRevision++;}); +initPermissionFlow({run:diagnosisUI.run,operate:diagnosisUI.operate,isBusy:()=>diagnosisUI.isBusy()||nodes.start.disabled, + recheck:checks=>runAudit({phase:'recheck',checks}), + getContext:()=>({report:lastSource==='current'?lastReport:null,revision:selectionRevision})}); const guideUI=initGuide({sessionPromise,run:diagnosisUI.run,retest:()=>runAudit({oneStop:true}),isBusy:diagnosisUI.isBusy,operate:diagnosisUI.operate, onReport:report=>{lastReport=report;renderDesktop(report);nodes.share.disabled=false;}}); @@ -157,10 +172,16 @@ async function runAudit({oneStop=true,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=diagnosisUI.isBusy();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(); + return lastReport; } -nodes.start.addEventListener("click",()=>{if(!diagnosisUI.isBusy())void runAudit({oneStop:true});}); +let startingAudit=false; +nodes.start.addEventListener("click",()=>{ + if(startingAudit||nodes.start.disabled||diagnosisUI.isBusy())return; + startingAudit=true; + void runAudit({oneStop:true}).finally(()=>{startingAudit=false;}); +}); function download(value,suffix) { if(value?.format==='zip'&&typeof value.data==='string'){ diff --git a/envscope/web/controls.mjs b/envscope/web/controls.mjs index 2d5fc11..9c158df 100644 --- a/envscope/web/controls.mjs +++ b/envscope/web/controls.mjs @@ -24,15 +24,19 @@ export async function initDesktopControls({sessionPromise,onReport,download,rete const session=await sessionPromise;if(!session)return; $("desktop-nav").hidden=false; let catalog=null,busy=false,lastReportId=null,statusRevision=0,statusPending=false; + let currentPage='audit';const scrollPositions=new Map(); document.addEventListener('envscope-audit-start',()=>{statusRevision++;}); document.addEventListener('envscope-operation-state',event=>{if(event.detail.busy)statusRevision++;}); function page(name){ + if(name!==currentPage)scrollPositions.set(currentPage,window.scrollY); 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 selected=document.querySelector(`.tab[data-page="${name}"]`); + $('more-menu-label').textContent=selected?.closest('#more-menu')?selected.textContent:'更多功能'; const heading=$('page-'+name)?.querySelector('h1');if(heading){heading.tabIndex=-1;heading.focus({preventScroll:true});} + currentPage=name;document.title=(selected?.textContent||'环境检查')+' · EnvScope'; + window.scrollTo(0,scrollPositions.get(name)||0); } 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();}}); diff --git a/envscope/web/desktop.mjs b/envscope/web/desktop.mjs index 0be7c83..39e2b8e 100644 --- a/envscope/web/desktop.mjs +++ b/envscope/web/desktop.mjs @@ -40,7 +40,11 @@ export function initDesktopApp({run,operate}){ $('app-update-install').addEventListener('click',()=>action('app-update-install',{version:desktop?.updates?.version})); $('app-update-restart').addEventListener('click',()=>action('app-update-restart')); $('app-update-rollback').addEventListener('click',()=>action('app-update-rollback')); - document.querySelector('[data-page="settings"]').addEventListener('click',()=>{if(desktop?.mode==='native')void run('desktop-status').then(v=>v.desktop&&render(v.desktop)).catch(()=>{});}); + document.querySelector('[data-page="settings"]').addEventListener('click',()=>{ + if(desktop?.mode==='native')void operate(async()=>{ + const value=await run('desktop-status');if(value.desktop)render(value.desktop); + },output); + }); document.addEventListener('envscope-catalog',e=>render(e.detail.desktop)); document.addEventListener('envscope-report',e=>{ report=e.detail.report;const a=report.assessment;$('qualification').hidden=!a;if(!a)return; @@ -51,18 +55,4 @@ export function initDesktopApp({run,operate}){ $('qualification-pending').replaceChildren(...(pending.length?pending.map(i=>{const p=el('p',''),f=report.findings.find(f=>f.id===i.id);p.append(el('strong',(f?.title||i.id)+':'),document.createTextNode(i.reason+' '+i.next_step));return p;}):[el('p','本次列出的关键使用条件已完成核对。')])); }); document.addEventListener('envscope-audit-start',()=>{report=null;$('qualification').hidden=true;}); - document.addEventListener('envscope-permission',e=>{ - if(!report)return; - void operate(async()=>{ - e.detail.button.disabled=true; - try{ - const params={report_id:report.report_id,finding_id:e.detail.id};let v=await run('permission-help',params); - if(v.state==='confirmation_required'){ - if(!window.confirm(v.reason)){e.detail.output.textContent='已取消,未更改权限。';return;} - v=await run('permission-help',params,true); - } - e.detail.output.textContent=v.reason||'完成授权后请重新检测。'; - }finally{e.detail.button.disabled=false;} - },e.detail.output); - }); } diff --git a/envscope/web/diagnosis.mjs b/envscope/web/diagnosis.mjs index 2a4830e..dc9de31 100644 --- a/envscope/web/diagnosis.mjs +++ b/envscope/web/diagnosis.mjs @@ -41,13 +41,13 @@ export function initDiagnosis({sessionPromise,retest}) { const busyObserver=new MutationObserver(updateRepairEntry); busyObserver.observe($('start'),{attributes:true,attributeFilter:['disabled']}); busyObserver.observe(document.documentElement,{attributes:true,attributeFilter:['data-operation-busy']}); - function setBusy(value){busy=value;for(const id of ['start','target-edit','target-confirm','target-cancel','quick-proxy','flow-upkeep-prepare','flow-upkeep-stop','flow-upkeep-interval','flow-upkeep-restore','enhance-cli','workflow-proxy','workflow-proxy-url','usage'])$(id).disabled=value;for(const node of document.querySelectorAll('#desktop-preferences button'))node.disabled=value;document.documentElement.dataset.operationBusy=value?'true':'false';updateRepairEntry();document.dispatchEvent(new CustomEvent('envscope-operation-state',{detail:{busy:value}}));} + function setBusy(value){busy=value;for(const id of ['start','target-edit','target-confirm','target-cancel','quick-proxy','flow-upkeep-prepare','flow-upkeep-stop','flow-upkeep-interval','flow-upkeep-restore','enhance-cli','workflow-proxy','workflow-proxy-url','usage'])$(id).disabled=value;for(const node of document.querySelectorAll('#desktop-preferences button,#desktop-preferences input'))node.disabled=value;document.documentElement.dataset.operationBusy=value?'true':'false';updateRepairEntry();document.dispatchEvent(new CustomEvent('envscope-operation-state',{detail:{busy:value}}));} async function run(action,params={},confirmed=false){ const session=await sessionPromise;if(!session)throw new Error('请打开本地桌面程序,再使用处理功能。'); const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),240000); try{ const response=await fetch('/api/action',{method:'POST',signal:controller.signal,headers:{'Content-Type':'application/json','X-EnvScope-Token':session.token},body:JSON.stringify({action,params,confirmed})}); - const value=await response.json();if(!response.ok)throw new Error(errorText[value.error]||value.error||'本次操作没有完成。');return value; + const value=await response.json();if(!response.ok){const error=new Error(errorText[value.error]||value.error||'本次操作没有完成。');error.code=value.error;throw error;}return value; }finally{clearTimeout(timer);} } async function action(fn,errorOutput=output){ diff --git a/envscope/web/icons.mjs b/envscope/web/icons.mjs new file mode 100644 index 0000000..4484179 --- /dev/null +++ b/envscope/web/icons.mjs @@ -0,0 +1,25 @@ +// Original MIT-licensed EnvScope line icons. All symbols retain visible labels. +const paths={ + scan:'M8 3H3v5m13-5h5v5M3 16v5h5m13-5v5h-5M8 12h8M12 8v8', + guard:'M12 3 4 6v6c0 5 8 9 8 9s8-4 8-9V6l-8-3Zm-4 9 3 3 5-6', + accounts:'M8 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-6 9v-2a6 6 0 0 1 12 0v2m3-15a4 4 0 0 1 0 8m1 3a5 5 0 0 1 4 4', + checks:'m3 6 1 1 2-3m-3 9 1 1 2-3m-3 9 1 1 2-3M10 6h11M10 13h11M10 20h11', + tools:'M14 6a5 5 0 0 0-6 6L3 17a2.8 2.8 0 0 0 4 4l5-5a5 5 0 0 0 6-6l-3 3-4-4 3-3Z', + history:'M3 11a9 9 0 1 1 2 7M3 4v7h7m2-5v6l4 3', + settings:'M4 6h16M4 12h16M4 18h16M8 3v6m8 0v6m-8 0v6', + coverage:'M3 3h7v7H3V3Zm11 0h7v7h-7V3ZM3 14h7v7H3v-7Zm11 0h7v7h-7v-7Z', + more:'M4 6h16M4 12h16M4 18h16', + help:'M9 8a3 3 0 1 1 5 2c-2 1-2 2-2 3m0 4h.01M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20', + power:'M12 2v10M6 5a9 9 0 1 0 12 0' +}; +export function icon(name){ + const svg=document.createElementNS('http://www.w3.org/2000/svg','svg'); + svg.setAttribute('viewBox','0 0 24 24');svg.setAttribute('class','ui-icon');svg.setAttribute('aria-hidden','true');svg.setAttribute('focusable','false'); + const path=document.createElementNS(svg.namespaceURI,'path');path.setAttribute('d',paths[name]||paths.scan);svg.append(path);return svg; +} +export function mountIcons(){ + const names={audit:'scan',upkeep:'guard',accounts:'accounts',checks:'checks',tools:'tools',history:'history',settings:'settings',coverage:'coverage'}; + for(const button of document.querySelectorAll('.tab[data-page]'))button.prepend(icon(names[button.dataset.page])); + for(const [id,name] of [['learning-replay','help'],['quit','power']])document.getElementById(id)?.prepend(icon(name)); + document.querySelector('#more-menu>summary')?.prepend(icon('more')); +} diff --git a/envscope/web/index.html b/envscope/web/index.html index aeb6252..40d2ac7 100644 --- a/envscope/web/index.html +++ b/envscope/web/index.html @@ -7,19 +7,25 @@ EnvScope — Claude 使用助手 + + +
-
EnvScope正在读取版本
+
EnvScope正在读取版本
-
+
+ +

正在连接本地服务…

@@ -203,15 +209,15 @@

未启用

进程守护记录

-