diff --git a/AGENTS.md b/AGENTS.md index d2d2fae..29cb3c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Product boundary -Makelore is a cross-platform Electron application with four enabled modules: `Makelore Code|AI 编程`, `Makelore Canvas|AI 绘画`, `Makelore Learning|AI 学习`, and `Makelore Robot|AI 机器`. The repository root is the complete product source tree. It does not depend on a sibling source checkout. +Makelore is a cross-platform Electron application with three enabled modules: `Makelore Code|AI 编程`, `Makelore Canvas|AI 绘画`, and `Makelore Robot|AI 机器`. The repository root is the complete product source tree. It does not depend on a sibling source checkout. Do not restore Works gallery, asset gallery, publish/upload, or cloud-deploy workbench pages. `/deliverables` is a project output preview and remains supported. diff --git a/README.md b/README.md index 533f85f..299500d 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,13 @@ **一念成光,万物可创。** -Makelore 是一个面向软件、视觉创作、互动学习与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含四个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: +Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工作台。当前版本为 `2.0.0`,包含三个已开通产品模块。模块入口页采用统一的横向卡片视觉,工作区左上角入口点击后返回模块入口页: - `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。 - `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。用户通过对话或直接编辑持续完善同一设计方向,再生成图片、单参考图作品或视频;参考素材可从当前项目作品选择或从本地上传。Canvas 侧栏提供“获取灵感”,进入服务端驱动的提示词博物馆。 - `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。 -- `Makelore Learning|AI 学习`:浏览运营精选的学习项目,阅读项目 README,并把经过完整性校验的 ZIP 源码包保存到电脑继续实践。 -应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 +应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与三个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。 作品广场、素材广场、独立发布上传和云部署页面不属于 Makelore 2.0 工作台。新建项目可选择“小游戏”“小程序”或“自定义项目”:小游戏和小程序会创建完整的平台发布模板,项目配置底部提供“一键提交审核”;Main 自动预检、安全打包并提交,构建通过后进入运营审核,审核通过即直接发布。首次创建必须选择 PNG、JPEG 或 WebP 项目封面,并通过 Main-owned multipart 原子接口同时保存资料与封面;已有 draft/published 只提交新版本并沿用平台现有资料与封面。自定义项目只创建工作空间,不配置默认发布方式。项目成果预览 `/deliverables` 继续保留。 @@ -29,7 +28,6 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 绘画生成:服务端从已确认规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。确认时只提交 Quote 身份。图片可使用当前 Workspace 的上传素材或生成作品,视频可绑定已审核首帧;任务与资产始终属于 Workspace。 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 -- AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 - 插件工作台:Code 侧栏只有一个“插件”入口,`/plugins` 在同一列表中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态;旧 Marketplace、My Plugins、Project Plugins URL 只做确定性筛选重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。 - 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 原生 Web Search 是冻结 selected model/provider/credential 的 parent-only 模型工具;只有 Works `model_capabilities` 明确声明支持时才出现,使用当前模型的正常计费链,不依赖 Marketplace 获取、Release、Admission、Hosted Provider 或 `agent_browser` 回退。 @@ -75,7 +73,7 @@ pnpm run package:win pnpm run package:linux ``` -各平台打包脚本会先准备目标架构所需的 Pi、Python 与 uv 运行时资源,产物写入忽略的 `release/` 目录。AI 学习不再携带独立播放器产物。正式包中的 Pi、Python 和 uv 均从安装目录解析;Agent Server 以已 staged 的 `resources/pi-runtime/package.json` 为解析锚点,并校验目标包入口仍位于该包内部。缺少本地资源或合法入口时启动和产物验证会直接失败,不会回退到应用 `node_modules`、系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 Pi 运行时。macOS、Windows 与 Linux 的各架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 +各平台打包脚本会先准备目标架构所需的 Pi、Python 与 uv 运行时资源,产物写入忽略的 `release/` 目录。正式包中的 Pi、Python 和 uv 均从安装目录解析;Agent Server 以已 staged 的 `resources/pi-runtime/package.json` 为解析锚点,并校验目标包入口仍位于该包内部。缺少本地资源或合法入口时启动和产物验证会直接失败,不会回退到应用 `node_modules`、系统 Python、npm 或 npx 下载。Git、项目编译器和用户选择的浏览器仍属于项目/系统工具,不属于内置 Pi 运行时。macOS、Windows 与 Linux 的各架构产物需要分别完成对应架构的 staging 与产物验证后再发布。 Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:pi:real` 和 `pnpm run perf:pi:release`。这里的 `real` 表示从最终产品可执行文件启动最终 `resources/pi-runtime`,并使用受控的 Provider-shaped 回环服务验证会话、工具、中止、结算、重开、并发隔离、子 Agent 与退出;它不表示真实外部 Provider 已验证。各目标平台、证据字段、兼容边界与整版本回滚步骤见 [`docs/pi-runtime-release-runbook.md`](docs/pi-runtime-release-runbook.md)。 @@ -100,7 +98,6 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - AI 编程项目配置只以项目内 `.makelore/project.json` 为准;项目文件和会话主数据保持本地,问答观察快照按个人资料同步规则单向上行。Main 不探测、读取或迁移 `.niancode` 与 `.opencode` 项目数据。 - AI 绘画 Renderer 只调用 Main-owned Host API;Main 负责 Works Square Token 刷新、Workspace 所属的持久 Agent Session、稳定命令身份、有界 Run 查询、可恢复事件订阅与契约映射,并通过本机 Host API 的 SSE 投影同步表单、任务和资产状态。切换 Workspace 只重连对应流;注销或退出时关闭本地流,不删除服务端持久 Session。远端 Token、Provider Prompt 与存储地址不进入 Renderer。 - AI 绘画使用独立的云端 Workspace 边界,不回退到 AI 编程项目数据,也不向 Renderer 暴露 Provider、模型、Prompt、存储 URI 或远端登录 Token。 -- AI 学习只通过 Main-owned Host API 获取项目列表、README 详情和封面/历史媒体路径;Renderer 不持有 Works Token、对象存储地址、任意归档 URL 或本地文件路径。项目 ZIP 只允许同 Works origin 最多五跳重定向,重定向请求不携带 Bearer;下载结果仅向 Renderer 返回 `saved` 或 `cancelled`。README 不执行原始 HTML,仅 Markdown 图片节点可直接加载服务端校验后的无凭据 HTTPS URL。 - Prompt Museum 使用独立的 Main-owned Host API 代理;Renderer 只接收分页卡片、详情和服务端返回的图片地址,Works Square Token 只由 Main 持有。发布记录必须由服务端完成作者、来源、许可证和素材授权审核,模块不提供投稿、点赞、评论或排行榜。 - AI 编程的智能体配置属于当前项目;稳定 id 用于保持对话兼容,显示名称可以修改。AI 绘画的设计 Agent 是固定产品能力,不作为用户可增删的项目实体。 diff --git a/docs/learning-project-catalog-server-contract.md b/docs/learning-project-catalog-server-contract.md deleted file mode 100644 index 68a5a8d..0000000 --- a/docs/learning-project-catalog-server-contract.md +++ /dev/null @@ -1,136 +0,0 @@ -# AI 学习项目目录服务端协作契约 - -> 状态:Makelore 客户端与 Electron Main 已按本契约实现;运营后台、数据库、对象存储与生产数据不在本仓库内,需在 Works Square 侧实现并部署后联调。 - -## 1. 产品与权限边界 - -AI 学习是登录后可用的精选项目目录。V1 只包含项目列表、README 详情和 ZIP 下载,不包含课程生成、课程播放器、本地课程库、学习进度、Agent、ASR、评分或课堂 runtime,也不兼容旧课程接口。 - -沿用现有账号与 `module_access.learning` 策略:客户端入口策略在页面初始化前拦截无权限账号,Works Square 的列表、详情、媒体和下载接口仍必须独立校验登录态及 Learning 权限,不能依赖客户端置灰。 - -Renderer 的固定链路为 `Renderer -> Host API -> Electron Main -> Works Square`。Renderer 不得获得 Works Token、对象存储凭据、内部存储 key、任意下载 URL或本地保存路径。 - -## 2. 运营后台“学习项目管理” - -运营后台新增一级菜单“学习项目管理”,至少支持新建、编辑、预览、发布、下架、排序和查看发布记录。项目字段如下: - -| 字段 | 规则 | -| --- | --- | -| 项目名称 `name` | 必填,1–200 字符 | -| 摘要 `summary` | 必填,1–2,000 字符,用于卡片 | -| 封面 `cover` | 必填,PNG/JPEG/WebP,最大 10 MiB;服务端读取真实 MIME、尺寸并生成受控媒体 | -| 项目压缩包 `archive` | 必填 ZIP;运营上传可保留独立的 512 MiB 服务端限制,服务端流式记录字节数、计算 SHA-256 并验证 ZIP 签名;该上传限制不由客户端在下载时执行 | -| README `readme` | 必填 `.md`,UTF-8,最大 500 KiB;不接受可执行 HTML 作为发布内容 | -| 标签 `tags` | 0–16 个,每项 1–64 字符,去重 | -| 版本 `version` | 可空,最大 64 字符,仅作展示 | -| 排序 `sort_order` | 有界整数;列表默认按运营排序,再按发布时间稳定排序 | -| 状态 `status` | `draft`、`published`、`archived` | - -上传中的对象不能直接进入公开目录。发布必须在一个事务/发布代际中冻结元数据、经过净化和 URL 校验的 README、封面及 ZIP 摘要;任一校验失败则整个发布失败,旧的已发布版本继续可读。下架后列表和详情立即不可见,但已有审计记录不能物理删除。 - -后台必须记录操作人、时间、发布代际、变更摘要、归档 SHA-256/字节数、README 警告和远程图片 URL 数量。客户端不提供任何运营上传或发布入口。 - -## 3. README 远程图片发布规则 - -README 中的远程图片在“发布”时由服务端解析 Markdown,并保留通过校验的原始 HTTPS URL;服务端不下载或处理图片字节。处理要求: - -1. 只接受无用户名/密码的 HTTPS URL;拒绝 `http:`、`data:`、`file:`、本地路径和协议相对地址。 -2. 只允许默认 HTTPS 端口且不允许 fragment;解析当前 DNS,任一结果属于 loopback、私网、链路本地、保留地址、云元数据或其他非公网地址时拒绝发布。 -3. 限制 README 图片总量;不请求远端响应,因此不校验重定向、响应大小、MIME、像素、实际格式或内容。SVG 及其他 Electron 可渲染格式可直接显示。 -4. 发布后的 Markdown 保留通过校验的 URL,不创建 README 图片 blob 或新 release-media 行;现有媒体路由继续用于封面和历史已镜像发布。 -5. Markdown 原始 HTML 在客户端被禁用;服务端也从发布内容中移除 HTML 并返回 warning,避免运营误判展示效果。 -6. 客户端请求会直接到第三方图片 origin;图片可用性、后续 DNS/重定向和格式支持由 origin 与 Electron 决定,origin 也会看到请求方网络信息。单图加载失败不得阻断 README 其余内容。 - -封面同样优先返回固定媒体路径。若返回 HTTPS CDN 地址,该地址必须无凭据、由 Works Square 控制且不包含用户隐私。 - -## 4. 客户端公开接口 - -| 方法 | Works Square 路径 | 用途 | -| --- | --- | --- | -| `GET` | `/api/learning/projects?cursor=&limit=` | 已发布项目分页列表 | -| `GET` | `/api/learning/projects/:id` | 项目详情和 README | -| `GET` | `/api/learning/projects/:id/media/:mediaId` | 受控封面/README raster 图片 | -| `GET` | `/api/learning/projects/:id/archive` | ZIP 字节流或同 Works origin 重定向 | - -`limit` 为 1–48,默认 24;`cursor` 是不透明游标。空列表返回 `200`,不要用 `404`。列表不返回 `readmeMarkdown`、`archiveSha256`、`archiveFileName` 或任何存储字段。 - -### 列表响应 - -```json -{ - "success": true, - "data": { - "items": [ - { - "id": "robot-arm", - "name": "桌面机械臂", - "summary": "从零搭建一个可以抓取积木的桌面机械臂。", - "cover": { - "url": "/api/learning/projects/robot-arm/media/cover", - "alt": "桌面机械臂成品", - "width": 1600, - "height": 900 - }, - "tags": ["机器人", "Python"], - "version": "1.2.0", - "archiveBytes": 12582912, - "publishedAt": "2026-08-01T00:00:00Z", - "updatedAt": "2026-08-18T00:00:00Z" - } - ], - "nextCursor": null, - "total": 1 - } -} -``` - -### 详情响应 - -详情复用全部列表字段,并增加: - -```json -{ - "success": true, - "data": { - "id": "robot-arm", - "name": "桌面机械臂", - "summary": "从零搭建一个可以抓取积木的桌面机械臂。", - "cover": { - "url": "/api/learning/projects/robot-arm/media/cover", - "alt": "桌面机械臂成品", - "width": 1600, - "height": 900 - }, - "tags": ["机器人", "Python"], - "version": "1.2.0", - "archiveBytes": 12582912, - "publishedAt": "2026-08-01T00:00:00Z", - "updatedAt": "2026-08-18T00:00:00Z", - "readmeMarkdown": "# 桌面机械臂\n\n![接线图](https://docs.example.com/wiring.svg)", - "archiveFileName": "makelore-robot-arm-1.2.0.zip", - "archiveSha256": "64位小写十六进制SHA-256" - } -} -``` - -TypeScript 权威字段定义位于 [`shared/learning.ts`](../shared/learning.ts)。未列出的内部字段会被 Main 丢弃。 - -### 媒体与归档响应 - -- 媒体接口只用于封面和历史已镜像内容,返回受控 raster 内容并设置准确 `Content-Type` 与 `Content-Length`;Main 限制 10 MiB,并转换为 data URL 给 Renderer。新发布 README 的 HTTPS 图片不经过该接口。 -- 归档接口返回 `application/zip`、`application/x-zip-compressed` 或 `application/octet-stream`;`Content-Length` 可省略,客户端也不使用它决定是否保存。如需重定向,只能跳转到与 Works API 相同 origin 的 HTTP(S) 地址,最多 5 跳;Main 不向重定向目标转发 Bearer。 -- `archiveBytes` 是列表、详情和运营审计使用的展示元数据,不是客户端下载门禁。客户端不比较它、`Content-Length` 与实际流字节数,也不设置归档大小上限;客户端仍要求实际归档匹配 `archiveSha256` 和 ZIP 签名,验证失败时删除临时文件,不留下部分下载。 - -## 5. 错误、缓存与上线顺序 - -统一使用现有 `{ success, status, code, error }` 错误封装。至少支持: - -- `LEARNING_AUTH_REQUIRED`(401); -- `LEARNING_FORBIDDEN`(403); -- `LEARNING_PROJECT_NOT_FOUND`(404); -- `LEARNING_CONFLICT`(409,发布代际变化); -- `LEARNING_UNAVAILABLE`(429/502/503)。 - -列表/详情可对发布代际生成 ETag;媒体和归档按内容摘要设置不可变缓存,但不得缓存带用户私有授权的响应到公共共享缓存。日志不得记录 Bearer、签名 URL、完整 README 图片 URL 或对象存储 key;可记录规范化主机、URL 摘要和图片数量。 - -上线顺序:先部署数据库/对象存储、运营后台、README HTTPS URL 校验和四个公开接口,再发布包含直连图片与无下载大小校验的新客户端;随后用真实账号完成发布/下架/远程图片(含 SVG、失效 origin 和隐私提示)/ZIP 联调,并覆盖缺失或不准确 `Content-Length` 的归档响应。旧 `/api/learning/courses`、generation/progress/runtime 接口不在新客户端兼容范围内,可按服务端消费者盘点结果独立退役。 diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 4168cbb..6bd875d 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -6,7 +6,6 @@ import { handleAppRoutes } from './routes/app'; import { handleAuthRoutes } from './routes/auth'; import { handleImageWorkspaceRoutes } from './routes/image-workspace'; import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum'; -import { handleLearningRoutes } from './routes/learning'; import { handleDataServiceRoutes } from './routes/data-service'; import { handleWorksRoutes } from './routes/works'; import { handleUserSyncRoutes } from './routes/user-sync'; @@ -44,7 +43,6 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleAuthRoutes, handleImageWorkspaceRoutes, handleImagePromptMuseumRoutes, - handleLearningRoutes, handleDataServiceRoutes, handleWorksRoutes, handleAgentBrowserRoutes, diff --git a/electron/api/routes/learning.ts b/electron/api/routes/learning.ts deleted file mode 100644 index c2bcece..0000000 --- a/electron/api/routes/learning.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { app, dialog, type SaveDialogOptions } from 'electron'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { join } from 'node:path'; -import { - LEARNING_MEDIA_MAX_BYTES, - type LearningProjectDetail, -} from '../../../shared/learning'; -import { - LearningProjectDownloadError, - safeLearningProjectArchiveFileName, - saveLearningProjectArchive, -} from '../../services/learning-project-download'; -import { - getValidWorksSquareAccessToken, - getWorksSquareAccountBinding, - isCurrentWorksSquareAccountBinding, - type WorksSquareAccountBinding, -} from '../../services/works-square-session'; -import { proxyAwareFetch } from '../../utils/proxy-fetch'; -import type { HostApiContext } from '../context'; -import { sendJson } from '../route-utils'; -import { WORKS_SQUARE_CONFIG } from '../works-config'; - -const LOCAL_ROOT = '/api/works/learning/projects'; -const UPSTREAM_ROOT = '/api/learning/projects'; -const PROJECT_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}'; -const MEDIA_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}'; -const PROJECT_ID_PATTERN = new RegExp(`^${PROJECT_ID}$`); -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const MEDIA_URL_PATTERN = new RegExp(`^/api/learning/projects/${PROJECT_ID}/media/${MEDIA_ID}$`); -const LOCAL_MEDIA_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/media/(${MEDIA_ID})$`); -const LOCAL_DOWNLOAD_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/download$`); -const LOCAL_DETAIL_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})$`); -const MAX_LIST_ITEMS = 48; -const MAX_TAGS = 16; -const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; -const TRUSTED_MEDIA_MIME_TYPES = new Set([ - 'image/avif', - 'image/gif', - 'image/jpeg', - 'image/png', - 'image/webp', -]); - -type LearningRoute = - | { kind: 'list'; upstreamPath: string } - | { kind: 'detail'; upstreamPath: string; projectId: string } - | { kind: 'media'; upstreamPath: string; projectId: string } - | { kind: 'download'; upstreamPath: string; projectId: string }; - -type Dependencies = { - fetchImpl?: typeof fetch; - getAccessToken?: typeof getValidWorksSquareAccessToken; - getAccountBinding?: () => WorksSquareAccountBinding | null; - isCurrentAccountBinding?: (value: WorksSquareAccountBinding) => boolean; - apiBaseUrl?: string; - chooseDestination?: (ctx: HostApiContext, fileName: string) => Promise; - saveArchive?: typeof saveLearningProjectArchive; -}; - -class InvalidLearningDataError extends Error {} - -function matchRoute(pathname: string): LearningRoute | null { - if (pathname === LOCAL_ROOT) return { kind: 'list', upstreamPath: UPSTREAM_ROOT }; - const media = LOCAL_MEDIA_PATH_PATTERN.exec(pathname); - if (media) { - return { - kind: 'media', - projectId: media[1], - upstreamPath: `${UPSTREAM_ROOT}/${media[1]}/media/${media[2]}`, - }; - } - const download = LOCAL_DOWNLOAD_PATH_PATTERN.exec(pathname); - if (download) { - return { - kind: 'download', - projectId: download[1], - upstreamPath: `${UPSTREAM_ROOT}/${download[1]}`, - }; - } - const detail = LOCAL_DETAIL_PATH_PATTERN.exec(pathname); - return detail - ? { kind: 'detail', projectId: detail[1], upstreamPath: `${UPSTREAM_ROOT}/${detail[1]}` } - : null; -} - -function listQuery(url: URL): string { - const query = new URLSearchParams(); - const cursor = url.searchParams.get('cursor')?.trim(); - if (cursor && cursor.length <= 1024) query.set('cursor', cursor); - const limit = url.searchParams.get('limit')?.trim(); - if (limit && /^\d{1,2}$/.test(limit) && Number(limit) >= 1 && Number(limit) <= MAX_LIST_ITEMS) { - query.set('limit', limit); - } - const encoded = query.toString(); - return encoded ? `?${encoded}` : ''; -} - -function asRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InvalidLearningDataError(); - return value as Record; -} - -function boundedString(value: unknown, maximum: number): string { - if (typeof value !== 'string') throw new InvalidLearningDataError(); - const normalized = value.trim(); - if (!normalized || normalized.length > maximum) throw new InvalidLearningDataError(); - return normalized; -} - -function nullableString(value: unknown, maximum: number): string | null { - return value === null ? null : boundedString(value, maximum); -} - -function boundedInteger(value: unknown, minimum: number, maximum: number): number { - if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { - throw new InvalidLearningDataError(); - } - return value as number; -} - -function isoTimestamp(value: unknown): string { - const timestamp = boundedString(value, 64); - if (!Number.isFinite(Date.parse(timestamp))) throw new InvalidLearningDataError(); - return timestamp; -} - -function mediaUrl(value: unknown): string { - const url = boundedString(value, 2048); - if (MEDIA_URL_PATTERN.test(url)) return url; - let parsed: URL; - try { - parsed = new URL(url); - } catch { - throw new InvalidLearningDataError(); - } - if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new InvalidLearningDataError(); - return parsed.toString(); -} - -function projectImage(value: unknown): Record { - const image = asRecord(value); - const width = image.width === undefined ? undefined : boundedInteger(image.width, 1, 32_768); - const height = image.height === undefined ? undefined : boundedInteger(image.height, 1, 32_768); - return { - url: mediaUrl(image.url), - alt: boundedString(image.alt, 500), - ...(width === undefined ? {} : { width }), - ...(height === undefined ? {} : { height }), - }; -} - -function projectSummary(value: unknown): Record { - const project = asRecord(value); - const id = boundedString(project.id, 128); - if (!PROJECT_ID_PATTERN.test(id)) throw new InvalidLearningDataError(); - const tags = Array.isArray(project.tags) && project.tags.length <= MAX_TAGS - ? project.tags.map((tag) => boundedString(tag, 64)) - : (() => { throw new InvalidLearningDataError(); })(); - if (new Set(tags).size !== tags.length) throw new InvalidLearningDataError(); - return { - id, - name: boundedString(project.name, 200), - summary: boundedString(project.summary, 2_000), - cover: projectImage(project.cover), - tags, - version: nullableString(project.version, 64), - archiveBytes: boundedInteger(project.archiveBytes, 1, Number.MAX_SAFE_INTEGER), - publishedAt: isoTimestamp(project.publishedAt), - updatedAt: isoTimestamp(project.updatedAt), - }; -} - -function projectDetail(value: unknown): LearningProjectDetail { - const project = asRecord(value); - const summary = projectSummary(project); - const archiveSha256 = boundedString(project.archiveSha256, 64); - if (!SHA256_PATTERN.test(archiveSha256)) throw new InvalidLearningDataError(); - const archiveFileName = boundedString(project.archiveFileName, 160); - if (!archiveFileName.toLowerCase().endsWith('.zip')) throw new InvalidLearningDataError(); - return { - ...summary, - readmeMarkdown: boundedString(project.readmeMarkdown, 500_000), - archiveFileName, - archiveSha256, - } as LearningProjectDetail; -} - -function unwrapEnvelope(value: unknown): unknown { - const envelope = asRecord(value); - if (envelope.success !== true || envelope.data === undefined) throw new InvalidLearningDataError(); - return envelope.data; -} - -function projectPage(value: unknown): Record { - const page = asRecord(unwrapEnvelope(value)); - if (!Array.isArray(page.items) || page.items.length > MAX_LIST_ITEMS) throw new InvalidLearningDataError(); - const nextCursor = page.nextCursor === null ? null : boundedString(page.nextCursor, 1024); - const total = page.total === undefined ? undefined : boundedInteger(page.total, 0, Number.MAX_SAFE_INTEGER); - return { - items: page.items.map(projectSummary), - nextCursor, - ...(total === undefined ? {} : { total }), - }; -} - -async function readBoundedJson(response: Response): Promise { - const declaredLength = Number(response.headers.get('content-length')); - if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { - await response.body?.cancel().catch(() => undefined); - throw new InvalidLearningDataError(); - } - if (!response.body) throw new InvalidLearningDataError(); - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > MAX_RESPONSE_BYTES) { - await reader.cancel().catch(() => undefined); - throw new InvalidLearningDataError(); - } - chunks.push(value); - } - try { - return JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')) as unknown; - } catch { - throw new InvalidLearningDataError(); - } -} - -function mediaMimeType(response: Response): string | null { - const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); - return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null; -} - -async function readBoundedMedia(response: Response): Promise { - const declaredLength = Number(response.headers.get('content-length')); - if (Number.isFinite(declaredLength) && declaredLength > LEARNING_MEDIA_MAX_BYTES) { - await response.body?.cancel().catch(() => undefined); - return null; - } - if (!response.body) return null; - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let size = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - size += value.byteLength; - if (size > LEARNING_MEDIA_MAX_BYTES) { - await reader.cancel().catch(() => undefined); - return null; - } - chunks.push(value); - } - return size > 0 ? Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) : null; -} - -function safeError(status: number): { status: number; code: string; error: string } { - if (status === 400 || status === 422) return { status, code: 'LEARNING_INVALID_REQUEST', error: '学习项目请求无效' }; - if (status === 401) return { status, code: 'LEARNING_AUTH_REQUIRED', error: '请先登录' }; - if (status === 403) return { status, code: 'LEARNING_FORBIDDEN', error: '没有权限访问学习项目' }; - if (status === 404) return { status, code: 'LEARNING_PROJECT_NOT_FOUND', error: '学习项目不存在或已下架' }; - if (status === 409) return { status, code: 'LEARNING_CONFLICT', error: '学习项目状态已变化,请刷新后重试' }; - const safeStatus = status >= 400 && status <= 599 ? status : 502; - return { - status: safeStatus, - code: 'LEARNING_UNAVAILABLE', - error: safeStatus === 429 ? '请求过于频繁,请稍后再试' : '学习项目服务暂时不可用', - }; -} - -function sendSafeError(res: ServerResponse, status: number): void { - const error = safeError(status); - sendJson(res, error.status, { success: false, ...error }); -} - -function sendInvalidResponse(res: ServerResponse): void { - sendJson(res, 502, { - success: false, - status: 502, - code: 'LEARNING_INVALID_RESPONSE', - error: '学习项目服务返回了无效数据', - }); -} - -async function defaultChooseDestination(ctx: HostApiContext, fileName: string): Promise { - const options: SaveDialogOptions = { - defaultPath: join(app.getPath('downloads'), fileName), - filters: [ - { name: 'ZIP 项目压缩包', extensions: ['zip'] }, - { name: '所有文件', extensions: ['*'] }, - ], - }; - const mainWindow = ctx.mainWindow && !ctx.mainWindow.isDestroyed() ? ctx.mainWindow : null; - const selection = mainWindow - ? await dialog.showSaveDialog(mainWindow, options) - : await dialog.showSaveDialog(options); - return selection.canceled || !selection.filePath ? null : selection.filePath; -} - -export function createLearningRouteHandler(dependencies: Dependencies = {}) { - const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; - const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; - const getAccountBinding = dependencies.getAccountBinding ?? getWorksSquareAccountBinding; - const isCurrentAccountBinding = dependencies.isCurrentAccountBinding ?? isCurrentWorksSquareAccountBinding; - const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); - const chooseDestination = dependencies.chooseDestination ?? defaultChooseDestination; - const saveArchive = dependencies.saveArchive ?? saveLearningProjectArchive; - - async function authorizedRequest(path: string, accept: string): Promise { - const token = await getAccessToken({ fetchImpl }); - if (!token) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); - const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, { - method: 'GET', - headers: { Accept: accept, Authorization: `Bearer ${accessToken}` }, - redirect: 'manual', - }); - let response = await request(token); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); - if (!refreshed) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); - response = await request(refreshed); - } - return response; - } - - async function readDetail(route: Extract): Promise { - const response = await authorizedRequest(route.upstreamPath, 'application/json'); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new LearningProjectDownloadError(response.status, 'LEARNING_PROJECT_REQUEST_FAILED', '学习项目暂时无法读取'); - } - const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response))); - if (detail.id !== route.projectId) throw new InvalidLearningDataError(); - return detail; - } - - return async function handleLearningRoutes( - req: IncomingMessage, - res: ServerResponse, - url: URL, - ctx: HostApiContext, - ): Promise { - const route = matchRoute(url.pathname); - if (!route) return false; - const expectedMethod = route.kind === 'download' ? 'POST' : 'GET'; - if (req.method !== expectedMethod) { - sendJson(res, 405, { - success: false, - status: 405, - code: 'LEARNING_METHOD_NOT_ALLOWED', - error: '不支持的学习项目请求', - }); - return true; - } - - try { - if (route.kind === 'download') { - const binding = getAccountBinding(); - if (!binding) { - sendSafeError(res, 401); - return true; - } - const detail = await readDetail(route); - if (!isCurrentAccountBinding(binding)) throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试'); - const fileName = safeLearningProjectArchiveFileName(detail.archiveFileName, detail.id); - const destinationPath = await chooseDestination(ctx, fileName); - if (!destinationPath) { - sendJson(res, 200, { success: true, data: { status: 'cancelled' } }); - return true; - } - await saveArchive({ - project: detail, - destinationPath, - binding, - fetchImpl, - getAccessToken, - isCurrentAccountBinding, - apiBaseUrl, - }); - sendJson(res, 200, { success: true, data: { status: 'saved' } }); - return true; - } - - const response = route.kind === 'list' - ? await authorizedRequest(`${route.upstreamPath}${listQuery(url)}`, 'application/json') - : await authorizedRequest(route.upstreamPath, route.kind === 'media' - ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' - : 'application/json'); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - sendSafeError(res, response.status); - return true; - } - - if (route.kind === 'media') { - const mimeType = mediaMimeType(response); - if (!mimeType) { - await response.body?.cancel().catch(() => undefined); - sendInvalidResponse(res); - return true; - } - const bytes = await readBoundedMedia(response); - if (!bytes) { - sendInvalidResponse(res); - return true; - } - sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType }); - return true; - } - - if (route.kind === 'list') { - sendJson(res, 200, { success: true, data: projectPage(await readBoundedJson(response)) }); - return true; - } - - const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response))); - if (detail.id !== route.projectId) throw new InvalidLearningDataError(); - sendJson(res, 200, { success: true, data: detail }); - return true; - } catch (error) { - if (error instanceof InvalidLearningDataError) { - sendInvalidResponse(res); - } else if (error instanceof LearningProjectDownloadError) { - sendJson(res, error.status, { - success: false, - status: error.status, - code: error.code, - error: error.message, - }); - } else { - sendSafeError(res, 502); - } - return true; - } - }; -} - -export const handleLearningRoutes = createLearningRouteHandler(); diff --git a/electron/main/background-lifecycle.ts b/electron/main/background-lifecycle.ts index e144fb4..36ee620 100644 --- a/electron/main/background-lifecycle.ts +++ b/electron/main/background-lifecycle.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'node:events'; -export type DesktopModule = 'programming' | 'painting' | 'learning' | 'robot' | 'other'; +export type DesktopModule = 'programming' | 'painting' | 'robot' | 'other'; export type DesktopActivity = { visible: boolean; diff --git a/electron/main/index.ts b/electron/main/index.ts index 2100f5c..0d24883 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -663,7 +663,7 @@ async function initialize(): Promise { }); // Coding workers are started on demand. Keeping them cold for - // Canvas/Learning/Robot avoids a resident child process on every launch. + // Canvas/Robot avoids a resident child process on every launch. void initializeBackgroundServices().catch((error) => { logger.warn('Deferred background initialization failed:', error); }); diff --git a/electron/services/learning-project-download.ts b/electron/services/learning-project-download.ts deleted file mode 100644 index 0b30d68..0000000 --- a/electron/services/learning-project-download.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { open, rename, rm } from 'node:fs/promises'; -import { basename, dirname, extname, join } from 'node:path'; -import type { LearningProjectDetail } from '../../shared/learning'; -import type { WorksSquareAccountBinding } from './works-square-session'; - -const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; -const SHA256_PATTERN = /^[0-9a-f]{64}$/; -const ARCHIVE_MIME_TYPES = new Set([ - 'application/octet-stream', - 'application/x-zip-compressed', - 'application/zip', -]); -const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); -export const LEARNING_PROJECT_DOWNLOAD_MAX_REDIRECTS = 5; - -export class LearningProjectDownloadError extends Error { - constructor( - readonly status: number, - readonly code: string, - message: string, - ) { - super(message); - this.name = 'LearningProjectDownloadError'; - } -} - -export function safeLearningProjectArchiveFileName(value: string, projectId: string): string { - const fallbackId = projectId.replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 48) || 'project'; - const normalized = [...basename(value.trim().replace(/\\/g, '/'))] - .map((character) => character.charCodeAt(0) < 32 ? '-' : character) - .join('') - .replace(/[<>:"/\\|?*]/g, '-') - .replace(/[. ]+$/g, '') - .slice(0, 120); - if (!normalized || extname(normalized).toLowerCase() !== '.zip') { - return `Makelore-${fallbackId}.zip`; - } - return normalized; -} - -function assertCurrentAccount( - binding: WorksSquareAccountBinding, - isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean, -): void { - if (!isCurrentAccountBinding(binding)) { - throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重新下载'); - } -} - -function validateProject(project: LearningProjectDetail): void { - if (!PROJECT_ID_PATTERN.test(project.id) - || !SHA256_PATTERN.test(project.archiveSha256)) { - throw new LearningProjectDownloadError(502, 'LEARNING_PROJECT_INVALID', '项目下载信息无效'); - } -} - -async function followRedirects(input: { - response: Response; - initialUrl: string; - apiOrigin: string; - fetchImpl: typeof fetch; - binding: WorksSquareAccountBinding; - isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; -}): Promise { - let response = input.response; - let currentUrl = new URL(input.initialUrl).href; - const visited = new Set([currentUrl]); - - for (let hop = 0; REDIRECT_STATUSES.has(response.status); hop += 1) { - if (hop >= LEARNING_PROJECT_DOWNLOAD_MAX_REDIRECTS) { - await response.body?.cancel().catch(() => undefined); - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_LIMIT', '项目下载重定向次数过多'); - } - const location = response.headers.get('location'); - await response.body?.cancel().catch(() => undefined); - if (!location) { - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址无效'); - } - let target: URL; - try { - target = new URL(location, currentUrl); - } catch { - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址无效'); - } - if (!['http:', 'https:'].includes(target.protocol) - || target.origin !== input.apiOrigin - || Boolean(target.username || target.password) - || visited.has(target.href)) { - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址不安全'); - } - visited.add(target.href); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - response = await input.fetchImpl(target, { - method: 'GET', - headers: { Accept: 'application/zip' }, - redirect: 'manual', - }); - currentUrl = target.href; - } - return response; -} - -async function writeVerifiedArchive(input: { - response: Response; - temporaryPath: string; - project: LearningProjectDetail; - binding: WorksSquareAccountBinding; - isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; -}): Promise { - if (!input.response.body) { - throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_EMPTY', '项目压缩包内容为空'); - } - const contentType = input.response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); - if (!contentType || !ARCHIVE_MIME_TYPES.has(contentType)) { - await input.response.body.cancel().catch(() => undefined); - throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_MIME_INVALID', '项目压缩包类型无效'); - } - const handle = await open(input.temporaryPath, 'wx'); - const hash = createHash('sha256'); - const signature: number[] = []; - const reader = input.response.body.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - for (const byte of value.subarray(0, Math.max(0, 4 - signature.length))) signature.push(byte); - hash.update(value); - await handle.write(value); - } - } finally { - reader.releaseLock(); - await handle.close(); - } - - if (signature.length < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b - || !((signature[2] === 0x03 && signature[3] === 0x04) - || (signature[2] === 0x05 && signature[3] === 0x06) - || (signature[2] === 0x07 && signature[3] === 0x08))) { - throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_INVALID', '项目压缩包不是有效的 ZIP 文件'); - } - if (hash.digest('hex') !== input.project.archiveSha256) { - throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_HASH_MISMATCH', '项目压缩包完整性校验失败'); - } -} - -export async function saveLearningProjectArchive(input: { - project: LearningProjectDetail; - destinationPath: string; - binding: WorksSquareAccountBinding; - fetchImpl: typeof fetch; - getAccessToken: (options?: { fetchImpl?: typeof fetch; forceRefresh?: boolean }) => Promise; - isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; - apiBaseUrl: string; -}): Promise { - validateProject(input.project); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - const apiBaseUrl = input.apiBaseUrl.replace(/\/+$/, ''); - const archiveUrl = `${apiBaseUrl}/api/learning/projects/${encodeURIComponent(input.project.id)}/archive`; - const request = (accessToken: string) => input.fetchImpl(archiveUrl, { - method: 'GET', - headers: { - Accept: 'application/zip', - Authorization: `Bearer ${accessToken}`, - }, - redirect: 'manual', - }); - - const token = await input.getAccessToken({ fetchImpl: input.fetchImpl }); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - if (!token) { - throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); - } - let response = await request(token); - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - const refreshed = await input.getAccessToken({ fetchImpl: input.fetchImpl, forceRefresh: true }); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - if (!refreshed) { - throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); - } - response = await request(refreshed); - } - response = await followRedirects({ - response, - initialUrl: archiveUrl, - apiOrigin: new URL(apiBaseUrl).origin, - fetchImpl: input.fetchImpl, - binding: input.binding, - isCurrentAccountBinding: input.isCurrentAccountBinding, - }); - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - throw new LearningProjectDownloadError( - response.status >= 400 && response.status <= 599 ? response.status : 502, - 'LEARNING_DOWNLOAD_FAILED', - '项目下载失败,请稍后重试', - ); - } - - const temporaryPath = join( - dirname(input.destinationPath), - `.${basename(input.destinationPath)}.${randomUUID()}.download`, - ); - try { - await writeVerifiedArchive({ - response, - temporaryPath, - project: input.project, - binding: input.binding, - isCurrentAccountBinding: input.isCurrentAccountBinding, - }); - assertCurrentAccount(input.binding, input.isCurrentAccountBinding); - await rename(temporaryPath, input.destinationPath); - } catch (error) { - await rm(temporaryPath, { force: true }).catch(() => undefined); - if (error instanceof LearningProjectDownloadError) throw error; - throw new LearningProjectDownloadError(500, 'LEARNING_SAVE_FAILED', '项目保存失败,请重新选择位置后重试'); - } -} diff --git a/shared/learning.ts b/shared/learning.ts deleted file mode 100644 index 24e1f0b..0000000 --- a/shared/learning.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** Stable project-catalog boundary between Makelore and Works Square. */ - -export const LEARNING_API_PATH = '/api/works/learning'; - -export const LEARNING_MEDIA_MAX_BYTES = 10 * 1024 * 1024; - -export type LearningProjectImage = { - url: string; - alt: string; - width?: number; - height?: number; -}; - -export type LearningProjectSummary = { - id: string; - name: string; - summary: string; - cover: LearningProjectImage; - tags: string[]; - version: string | null; - archiveBytes: number; - publishedAt: string; - updatedAt: string; -}; - -export type LearningProjectDetail = LearningProjectSummary & { - readmeMarkdown: string; - archiveFileName: string; - archiveSha256: string; -}; - -export type LearningProjectPage = { - items: LearningProjectSummary[]; - nextCursor: string | null; - total?: number; -}; - -export type LearningProjectListQuery = { - cursor?: string; - limit?: number; -}; - -export type LearningProjectDownloadResult = { - status: 'saved' | 'cancelled'; -}; diff --git a/shared/module-access.ts b/shared/module-access.ts index dcc77ad..412a954 100644 --- a/shared/module-access.ts +++ b/shared/module-access.ts @@ -1,7 +1,6 @@ export const MODULE_ACCESS_KEYS = [ 'programming', 'design', - 'learning', 'robot', ] as const; @@ -12,7 +11,6 @@ export type ModuleAccess = Record; export const DEFAULT_MODULE_ACCESS: ModuleAccess = { programming: true, design: true, - learning: true, robot: true, }; @@ -24,7 +22,6 @@ export function normalizeModuleAccess(value: unknown): ModuleAccess { return { programming: typeof record.programming === 'boolean' ? record.programming : true, design: typeof record.design === 'boolean' ? record.design : true, - learning: typeof record.learning === 'boolean' ? record.learning : true, robot: typeof record.robot === 'boolean' ? record.robot : true, }; } diff --git a/src/App.tsx b/src/App.tsx index c97a8d8..f1cd4a1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,8 +42,6 @@ const Settings = lazy(() => import('./pages/Settings').then(({ Settings: compone const Setup = lazy(() => import('./pages/Setup').then(({ Setup: component }) => ({ default: component }))); const Login = lazy(() => import('./pages/Login').then(({ Login: component }) => ({ default: component }))); const ModuleSelection = lazy(() => import('./pages/ModuleSelection').then(({ ModuleSelection: component }) => ({ default: component }))); -const Learning = lazy(() => import('./pages/Learning').then(({ Learning: component }) => ({ default: component }))); -const LearningProjectDetail = lazy(() => import('./pages/Learning/ProjectDetail').then(({ LearningProjectDetail: component }) => ({ default: component }))); /** * Error Boundary to catch and display React rendering errors @@ -129,7 +127,6 @@ function moduleForPath(pathname: string): DesktopActivityModule | null { const module = getGuardedAiModuleForPath(pathname); if (module === 'programming') return 'programming'; if (module === 'painting') return 'painting'; - if (module === 'learning') return 'learning'; if (module === 'robot') return 'robot'; return null; } @@ -457,8 +454,6 @@ function App() { } /> } /> } /> - } /> - } /> } /> } /> } /> diff --git a/src/assets/module-learning.webp b/src/assets/module-learning.webp deleted file mode 100644 index 0ecd91b..0000000 Binary files a/src/assets/module-learning.webp and /dev/null differ diff --git a/src/components/layout/LearningSidebar.tsx b/src/components/layout/LearningSidebar.tsx deleted file mode 100644 index 506ff71..0000000 --- a/src/components/layout/LearningSidebar.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FolderKanban } from 'lucide-react'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { cn } from '@/lib/utils'; - -type LearningSidebarProps = { - sidebarCollapsed: boolean; -}; - -export function LearningSidebar({ sidebarCollapsed }: LearningSidebarProps) { - const navigate = useNavigate(); - const location = useLocation(); - const active = location.pathname === '/learning' || location.pathname.startsWith('/learning/project/'); - - return ( - - ); -} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b0e8f44..009aebb 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -40,7 +40,6 @@ import { useCurrentUserProfile } from '@/hooks/use-current-user-profile'; import type { SidebarPeekSource } from './sidebar-peek'; import { ModuleSwitcher } from './ModuleSwitcher'; import { ImageWorkspaceSidebar } from './ImageWorkspaceSidebar'; -import { LearningSidebar } from './LearningSidebar'; import { SidebarUpdateButton } from './SidebarUpdateButton'; import { UserAvatar } from '@/components/profile/UserAvatar'; import { getAccountInitial } from '@/components/profile/user-avatar-utils'; @@ -185,7 +184,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi const activeModule = getAiModuleForPath(location.pathname); const isProgrammingModule = activeModule === 'programming'; const isPaintingModule = activeModule === 'painting'; - const isLearningModule = activeModule === 'learning'; const isRobotModule = activeModule === 'robot'; const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/'); const projectConfigPath = '/project-config'; @@ -672,8 +670,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi ) : null} - ) : isLearningModule ? ( - ) : (
- -
- - - ); - } - - return ( -
-
- - -
- -
- -
-
- {project.tags.map((tag) => ( - {tag} - ))} -
-

{project.name}

-

{project.summary}

-
- ZIP · {formatBytes(project.archiveBytes)} - {project.version ? `版本 ${project.version}` : '最新版'} - 更新于 {new Date(project.updatedAt).toLocaleDateString('zh-CN')} -
-
-
- -
-

项目说明

-
-

{children}

, - h2: ({ children }) =>

{children}

, - h3: ({ children }) =>

{children}

, - p: ({ children }) =>

{children}

, - a: ({ href, children }) => isHttpsLink(href) ? ( - {children} - ) : {children}, - img: ({ src, alt }) => typeof src === 'string' ? ( - - ) : null, - pre: ({ children }) =>
{children}
, - code: ({ children, className }) => {children}, - blockquote: ({ children }) =>
{children}
, - table: ({ children }) =>
{children}
, - }} - > - {project.readmeMarkdown} -
-
-
-
- ); -} diff --git a/src/pages/Learning/ProjectImage.tsx b/src/pages/Learning/ProjectImage.tsx deleted file mode 100644 index 8f40b4e..0000000 --- a/src/pages/Learning/ProjectImage.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useEffect, useState } from 'react'; -import { ImageOff, Loader2 } from 'lucide-react'; -import { - fetchLearningProjectMedia, - isLearningProjectMediaUrl, - isSafeLearningImageUrl, -} from '@/lib/learning'; -import { cn } from '@/lib/utils'; - -type ProjectImageProps = { - src: string; - alt: string; - className?: string; - allowHttps?: boolean; -}; - -export function ProjectImage({ src, alt, className, allowHttps = false }: ProjectImageProps) { - const controlledMedia = isLearningProjectMediaUrl(src); - const directSource = allowHttps && isSafeLearningImageUrl(src) && !controlledMedia ? src : null; - const [failedSource, setFailedSource] = useState(null); - const [state, setState] = useState<{ input: string; source: string; status: 'ready' | 'error' }>({ - input: '', - source: '', - status: 'error', - }); - - useEffect(() => { - if (!controlledMedia) return undefined; - let cancelled = false; - void fetchLearningProjectMedia(src).then((source) => { - if (!cancelled) setState({ input: src, source, status: 'ready' }); - }).catch(() => { - if (!cancelled) setState({ input: src, source: '', status: 'error' }); - }); - return () => { cancelled = true; }; - }, [controlledMedia, src]); - - if (failedSource === src || (!controlledMedia && !directSource)) { - return ( - - - - ); - } - if (controlledMedia && state.input !== src) { - return ( - - - - ); - } - if (controlledMedia && state.status === 'error') { - return ( - - - - ); - } - return ( - {alt} setFailedSource(src)} - /> - ); -} diff --git a/src/pages/Learning/index.tsx b/src/pages/Learning/index.tsx deleted file mode 100644 index d08f514..0000000 --- a/src/pages/Learning/index.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { ArrowRight, FolderArchive, Loader2, RefreshCw } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; -import { Button } from '@/components/ui/button'; -import { fetchLearningProjects } from '@/lib/learning'; -import type { LearningProjectSummary } from '../../../shared/learning'; -import { ProjectImage } from './ProjectImage'; - -function formatBytes(bytes: number): string { - if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; - if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`; - return `${Math.max(1, Math.round(bytes / 1024))} KB`; -} - -function mergeProjects( - current: LearningProjectSummary[], - incoming: LearningProjectSummary[], -): LearningProjectSummary[] { - const projects = new Map(current.map((project) => [project.id, project])); - for (const project of incoming) projects.set(project.id, project); - return [...projects.values()]; -} - -export function Learning() { - const navigate = useNavigate(); - const [projects, setProjects] = useState([]); - const [nextCursor, setNextCursor] = useState(null); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - - const loadProjects = useCallback(async (cursor?: string) => { - const append = Boolean(cursor); - if (append) setLoadingMore(true); - else setLoading(true); - setError(null); - try { - const page = await fetchLearningProjects({ cursor, limit: 24 }); - setProjects((current) => append ? mergeProjects(current, page.items) : page.items); - setNextCursor(page.nextCursor); - } catch (cause) { - setError(cause instanceof Error ? cause.message : '学习项目暂时无法加载'); - } finally { - if (append) setLoadingMore(false); - else setLoading(false); - } - }, []); - - useEffect(() => { - void loadProjects(); - }, [loadProjects]); - - return ( -
-
-
-

动手学习

-

学习项目

-

- 选择一个感兴趣的项目,先阅读完整介绍,再把源码压缩包保存到电脑继续实践。 -

-
- -
- - {loading ? ( -
- 正在读取项目 -
- ) : error && projects.length === 0 ? ( -
-

项目列表加载失败

-

{error}

- -
- ) : projects.length === 0 ? ( -
- -

暂时还没有已发布项目

-

运营发布后,项目会直接出现在这里。

-
- ) : ( - <> - {error ?

{error}

: null} -
- {projects.map((project) => ( -
- -
- ))} -
- {nextCursor ? ( -
- -
- ) : null} - - )} -
- ); -} diff --git a/src/pages/ModuleSelection/index.tsx b/src/pages/ModuleSelection/index.tsx index 8cd05bb..efe15d8 100644 --- a/src/pages/ModuleSelection/index.tsx +++ b/src/pages/ModuleSelection/index.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import moduleCanvasImage from '@/assets/module-canvas-neon.webp'; import moduleGameImage from '@/assets/module-game.webp'; -import moduleLearningImage from '@/assets/module-learning.webp'; import moduleRobotImage from '@/assets/module-robot.webp'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import { UserProfileDialog } from '@/components/profile/UserProfileDialog'; @@ -16,7 +15,6 @@ import { getUserProfileDisplayName } from '@/stores/user-profile'; const moduleSelectionContent: Record = { programming: { label: 'Code 编程', image: moduleGameImage, imageAlt: '游戏创作工作台' }, painting: { label: 'Canvas 设计', image: moduleCanvasImage, imageAlt: '数位板设计创作' }, - learning: { label: 'Learning 学习', image: moduleLearningImage, imageAlt: '数学科技宇宙' }, robot: { label: 'Robot 机器', image: moduleRobotImage, imageAlt: '青少年管理机器人硬件与设备绑定' }, }; diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 5855cf5..48be8dc 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -785,7 +785,7 @@ export const useAuthStore = create()( }), { name: 'niancode-auth', - version: 3, + version: 4, migrate: (persistedState: unknown) => { const state = persistedState && typeof persistedState === 'object' ? persistedState as Record diff --git a/tests/e2e/image-workspace-v2.spec.ts b/tests/e2e/image-workspace-v2.spec.ts index 4bd5277..0ba93e0 100644 --- a/tests/e2e/image-workspace-v2.spec.ts +++ b/tests/e2e/image-workspace-v2.spec.ts @@ -231,7 +231,7 @@ test.describe('AI Design V2 workspace', () => { e2eGlobal.__designE2eAuthReady = true; return respond({ success: true, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); } if (path === '/api/works/user/agent-profile') { @@ -389,7 +389,7 @@ test.describe('AI Design V2 workspace', () => { deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }, version: 2, })); diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index a83fc91..0a9b53e 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -1,7 +1,7 @@ import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; test.describe('Makelore module navigation without setup flow', () => { - test('keeps the four module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { + test('keeps the three module entries in a proportional vertical stack while resizing the window', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { @@ -23,7 +23,7 @@ test.describe('Makelore module navigation without setup flow', () => { })); const initialMetrics = await readCardMetrics(); - expect(initialMetrics).toHaveLength(4); + expect(initialMetrics).toHaveLength(3); await expect(moduleCards.first().locator('.module-option-card-arrow')).toHaveCount(0); await expect(moduleCards.first().locator('.module-option-artwork')).toHaveCount(0); await expect.poll(async () => await moduleCards.first().evaluate((element) => { @@ -75,7 +75,7 @@ test.describe('Makelore module navigation without setup flow', () => { await expect.poll(async () => (await readCardMetrics())[0]?.width ?? 0).toBeLessThan(initialWidth - 1); const resizedMetrics = await readCardMetrics(); - expect(resizedMetrics).toHaveLength(4); + expect(resizedMetrics).toHaveLength(3); expect(Math.max(...resizedMetrics.map((card) => card.left)) - Math.min(...resizedMetrics.map((card) => card.left))).toBeLessThan(1); expect(resizedMetrics.map((card) => card.top)).toEqual( [...resizedMetrics].sort((top, bottom) => top.top - bottom.top).map((card) => card.top), @@ -108,23 +108,18 @@ test.describe('Makelore module navigation without setup flow', () => { } }); - test('opens Learning from the module chooser and returns through the module switcher', async ({ launchElectronApp }) => { + test('omits Learning from the module chooser and redirects its retired route', async ({ launchElectronApp }) => { const app = await launchElectronApp({ skipSetup: true }); try { const page = await getStableWindow(app); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); - const learningOption = page.getByTestId('ai-module-option-learning'); - await expect(learningOption).toBeVisible(); - await expect(learningOption).toBeEnabled(); - await learningOption.click(); - - await expect(page).toHaveURL(/\/learning$/); - await expect(page.getByTestId('learning-home')).toBeVisible(); - await expect(page.getByRole('heading', { name: '学习项目' })).toBeVisible(); - - await page.getByTestId('sidebar-module-switcher-trigger').click(); + await expect(page.locator('[data-testid^="ai-module-option-"]')).toHaveCount(3); + await expect(page.getByTestId('ai-module-option-learning')).toHaveCount(0); + await page.evaluate(() => { + window.location.hash = '#/learning'; + }); await expect(page).toHaveURL(/\/module-select$/); await expect(page.getByTestId('ai-module-selection-page')).toBeVisible(); } finally { diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index dc35059..4d665fe 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -47,7 +47,7 @@ test.describe('Unified plugin workspace', () => { user: { username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); if (requestPath === '/api/works/user/agent-profile') return result({ success: true, @@ -239,7 +239,7 @@ test.describe('Unified plugin workspace', () => { user: { username: 'plugins-e2e', userId: 'plugins-e2e-user', tenantId: null, deptId: null, authorities: [], }, - moduleAccess: { programming: true, design: true, learning: true, robot: true }, + moduleAccess: { programming: true, design: true, robot: true }, }); if (requestPath === '/api/works/user/agent-profile') return result({ success: true, diff --git a/tests/unit/app-module-provider-gate.test.tsx b/tests/unit/app-module-provider-gate.test.tsx index 24684c1..edc7385 100644 --- a/tests/unit/app-module-provider-gate.test.tsx +++ b/tests/unit/app-module-provider-gate.test.tsx @@ -63,7 +63,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, init: vi.fn(), @@ -116,7 +115,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -131,7 +129,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: false, design: true, - learning: true, robot: true, }, }); @@ -147,8 +144,6 @@ describe('App programming provider initialization gate', () => { ['/chat', 'programming'], ['/image-canvas', 'design'], ['/image-prompts/example', 'design'], - ['/learning', 'learning'], - ['/learning/project/project-1', 'learning'], ['/ai-hardware', 'robot'], ['/ai-hardware/device-1', 'robot'], ] as const)('redirects disabled %s routes before mounting their module', async (pathname, accessKey) => { @@ -156,7 +151,6 @@ describe('App programming provider initialization gate', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, [accessKey]: false, }, @@ -171,12 +165,22 @@ describe('App programming provider initialization gate', () => { } }); + it.each(['/learning', '/learning/project/project-1'])( + 'redirects retired Learning route %s to the module chooser', + async (pathname) => { + await renderAt(pathname); + + expect(await screen.findByText('Module chooser')).toBeInTheDocument(); + expect(screen.queryByTestId('main-layout')).not.toBeInTheDocument(); + expect(initProviders).not.toHaveBeenCalled(); + }, + ); + it('keeps global settings available when Programming is disabled', async () => { useAuthStore.setState({ moduleAccess: { programming: false, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/auth-routes.test.ts b/tests/unit/auth-routes.test.ts index 6636626..650793a 100644 --- a/tests/unit/auth-routes.test.ts +++ b/tests/unit/auth-routes.test.ts @@ -115,10 +115,10 @@ describe('auth host api routes', () => { moduleAccess: { programming: false, design: true, - learning: false, robot: true, }, }); + expect(response.json()).not.toHaveProperty('moduleAccess.learning'); expect(JSON.stringify(response.json())).not.toContain('must-not-reach-renderer'); expect(fetchMock).toHaveBeenCalledWith( 'https://square.nianxx.cn/api/auth/me', @@ -161,7 +161,6 @@ describe('auth host api routes', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/auth-store.test.ts b/tests/unit/auth-store.test.ts index 2e5e24a..edfdc10 100644 --- a/tests/unit/auth-store.test.ts +++ b/tests/unit/auth-store.test.ts @@ -24,7 +24,6 @@ function resetAuthStore() { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -88,7 +87,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: false, }, }); @@ -129,7 +127,6 @@ describe('auth store', () => { expect(state.moduleAccess).toEqual({ programming: true, design: false, - learning: true, robot: false, }); expect(window.localStorage.getItem('niancode-auth')).not.toContain( @@ -158,7 +155,7 @@ describe('auth store', () => { }) .mockResolvedValueOnce({ success: true, - moduleAccess: { learning: false }, + moduleAccess: { robot: false }, }); await useAuthStore.getState().loginWithMobile({ @@ -179,8 +176,7 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: false, - robot: true, + robot: false, }, }); }); @@ -217,7 +213,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -261,7 +256,6 @@ describe('auth store', () => { expect(useAuthStore.getState().moduleAccess).toEqual({ programming: true, design: false, - learning: true, robot: true, }); }); @@ -315,7 +309,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: true, }, }); @@ -422,7 +415,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: false, - learning: true, robot: true, }, user: { @@ -445,7 +437,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -488,7 +479,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); @@ -952,7 +942,6 @@ describe('auth store', () => { moduleAccess: { programming: true, design: true, - learning: true, robot: true, }, }); diff --git a/tests/unit/background-lifecycle.test.ts b/tests/unit/background-lifecycle.test.ts index aebc5ef..cffcd90 100644 --- a/tests/unit/background-lifecycle.test.ts +++ b/tests/unit/background-lifecycle.test.ts @@ -64,7 +64,7 @@ describe('BackgroundLifecycleController', () => { onStopRuntime, }); - controller.setActivity({ visible: false, module: 'learning' }); + controller.setActivity({ visible: false, module: 'painting' }); await vi.advanceTimersByTimeAsync(60_000); expect(onSleep).toHaveBeenCalledTimes(1); @@ -90,7 +90,7 @@ describe('BackgroundLifecycleController', () => { onStopRuntime, }); - controller.setActivity({ visible: false, module: 'learning' }); + controller.setActivity({ visible: false, module: 'robot' }); await vi.advanceTimersByTimeAsync(30_000); controller.setActivity({ visible: true, module: 'programming' }); await vi.advanceTimersByTimeAsync(90_000); diff --git a/tests/unit/host-api-dispatcher.test.ts b/tests/unit/host-api-dispatcher.test.ts index ac30aa4..8e63fc8 100644 --- a/tests/unit/host-api-dispatcher.test.ts +++ b/tests/unit/host-api-dispatcher.test.ts @@ -29,4 +29,19 @@ describe('Host API transport selection', () => { expect(data).toMatchObject({ status: 200, ok: true }); expect(data.json).toMatchObject({ runtime: 'pi', diagnostics: { revision: 3 } }); }); + + it('leaves the retired Learning Host API unregistered', async () => { + const data = await dispatchHostApiRequest({} as never, { + path: '/api/works/learning/projects', + }); + + expect(data).toMatchObject({ + status: 404, + ok: false, + json: { + success: false, + error: 'No route for GET /api/works/learning/projects', + }, + }); + }); }); diff --git a/tests/unit/learning-client.test.ts b/tests/unit/learning-client.test.ts deleted file mode 100644 index 767bef2..0000000 --- a/tests/unit/learning-client.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - downloadLearningProject, - fetchLearningProject, - fetchLearningProjectMedia, - fetchLearningProjects, - openLearningExternalLink, -} from '@/lib/learning'; - -const hostApiFetchMock = vi.hoisted(() => vi.fn()); -const invokeIpcMock = vi.hoisted(() => vi.fn()); - -vi.mock('@/lib/host-api', () => ({ - hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), -})); - -vi.mock('@/lib/api-client', () => ({ - invokeIpc: (...args: unknown[]) => invokeIpcMock(...args), -})); - -describe('Learning project renderer client', () => { - beforeEach(() => { - hostApiFetchMock.mockReset(); - invokeIpcMock.mockReset(); - }); - - it('reads paged projects and project detail through the Host API', async () => { - const page = { items: [], nextCursor: 'next' }; - const detail = { id: 'project-1', name: '机械臂' }; - hostApiFetchMock - .mockResolvedValueOnce({ success: true, data: page }) - .mockResolvedValueOnce({ success: true, data: detail }); - - await expect(fetchLearningProjects({ cursor: 'cursor one', limit: 24 })).resolves.toBe(page); - await expect(fetchLearningProject('project/one')).resolves.toBe(detail); - - expect(hostApiFetchMock).toHaveBeenNthCalledWith( - 1, - '/api/works/learning/projects?cursor=cursor+one&limit=24', - undefined, - ); - expect(hostApiFetchMock).toHaveBeenNthCalledWith( - 2, - '/api/works/learning/projects/project%2Fone', - undefined, - ); - }); - - it('starts the native Main-owned download through a fixed Host route', async () => { - hostApiFetchMock.mockResolvedValue({ success: true, data: { status: 'saved' } }); - - await expect(downloadLearningProject('project-1')).resolves.toEqual({ status: 'saved' }); - - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/works/learning/projects/project-1/download', - { method: 'POST' }, - ); - expect(invokeIpcMock).not.toHaveBeenCalled(); - }); - - it('accepts only fixed project media paths and validated raster data', async () => { - hostApiFetchMock.mockResolvedValue({ mimeType: 'image/png', dataBase64: 'AQID' }); - - await expect(fetchLearningProjectMedia( - '/api/learning/projects/project-1/media/readme-1', - )).resolves.toBe('data:image/png;base64,AQID'); - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/works/learning/projects/project-1/media/readme-1', - ); - - await expect(fetchLearningProjectMedia('https://tracker.example/image.png')).rejects.toMatchObject({ - code: 'LEARNING_INVALID_MEDIA_URL', - }); - hostApiFetchMock.mockResolvedValueOnce({ mimeType: 'image/svg+xml', dataBase64: 'AQID' }); - await expect(fetchLearningProjectMedia( - '/api/learning/projects/project-1/media/readme-2', - )).rejects.toMatchObject({ code: 'LEARNING_INVALID_MEDIA_RESPONSE' }); - }); - - it('opens only credential-free HTTPS README links through the IPC facade', async () => { - invokeIpcMock.mockResolvedValue(undefined); - - await openLearningExternalLink('https://example.com/guide'); - expect(invokeIpcMock).toHaveBeenCalledWith('shell:openExternal', 'https://example.com/guide'); - await expect(openLearningExternalLink('http://example.com')).rejects.toMatchObject({ - code: 'LEARNING_INVALID_LINK', - }); - }); -}); diff --git a/tests/unit/learning-page.test.tsx b/tests/unit/learning-page.test.tsx deleted file mode 100644 index 57f7ce3..0000000 --- a/tests/unit/learning-page.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; -import { Learning } from '@/pages/Learning'; -import { LearningProjectDetail } from '@/pages/Learning/ProjectDetail'; - -const fetchLearningProjectsMock = vi.hoisted(() => vi.fn()); -const fetchLearningProjectMock = vi.hoisted(() => vi.fn()); -const downloadLearningProjectMock = vi.hoisted(() => vi.fn()); -const openLearningExternalLinkMock = vi.hoisted(() => vi.fn()); - -vi.mock('@/lib/learning', () => ({ - fetchLearningProjects: fetchLearningProjectsMock, - fetchLearningProject: fetchLearningProjectMock, - downloadLearningProject: downloadLearningProjectMock, - openLearningExternalLink: openLearningExternalLinkMock, -})); - -vi.mock('@/pages/Learning/ProjectImage', () => ({ - ProjectImage: ({ src, alt, allowHttps }: { src: string; alt: string; allowHttps?: boolean }) => ( - {alt} - ), -})); - -const project = { - id: 'robot-arm', - name: '桌面机械臂', - summary: '从零搭建一个可以抓取积木的桌面机械臂。', - cover: { - url: '/api/learning/projects/robot-arm/media/cover', - alt: '桌面机械臂封面', - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 12 * 1024 * 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', -}; - -function LocationProbe() { - const location = useLocation(); - return {location.pathname}; -} - -describe('Learning project pages', () => { - beforeEach(() => { - fetchLearningProjectsMock.mockReset(); - fetchLearningProjectMock.mockReset(); - downloadLearningProjectMock.mockReset(); - openLearningExternalLinkMock.mockReset(); - openLearningExternalLinkMock.mockResolvedValue(undefined); - }); - - it('renders project cards and opens a project detail route', async () => { - fetchLearningProjectsMock.mockResolvedValue({ items: [project], nextCursor: null }); - render( - - } /> - , - ); - - expect(await screen.findByTestId('learning-project-robot-arm')).toHaveTextContent('桌面机械臂'); - expect(screen.getByAltText('桌面机械臂封面')).toHaveAttribute('src', project.cover.url); - fireEvent.click(screen.getByRole('button', { name: '查看项目:桌面机械臂' })); - expect(screen.getByTestId('location-path')).toHaveTextContent('/learning/project/robot-arm'); - }); - - it('loads more projects with the opaque cursor', async () => { - fetchLearningProjectsMock - .mockResolvedValueOnce({ items: [project], nextCursor: 'cursor-2' }) - .mockResolvedValueOnce({ items: [{ ...project, id: 'robot-car', name: '智能小车' }], nextCursor: null }); - render(); - - fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); - expect(await screen.findByText('智能小车')).toBeInTheDocument(); - expect(fetchLearningProjectsMock).toHaveBeenNthCalledWith(2, { cursor: 'cursor-2', limit: 24 }); - }); - - it('renders Markdown without raw HTML and allows direct HTTPS README images', async () => { - fetchLearningProjectMock.mockResolvedValue({ - ...project, - archiveFileName: 'robot-arm.zip', - archiveSha256: 'a'.repeat(64), - readmeMarkdown: '# 开始搭建\n\n![接线图](https://images.example.com/wiring.svg)\n\n\n\n[参考资料](https://example.com/guide)', - }); - render( - - } /> - , - ); - - expect(await screen.findByRole('heading', { name: '开始搭建' })).toBeInTheDocument(); - expect(screen.getByAltText('接线图')).toHaveAttribute( - 'src', - 'https://images.example.com/wiring.svg', - ); - expect(screen.getByAltText('接线图')).toHaveAttribute('data-allow-https', 'true'); - expect(document.querySelector('script')).toBeNull(); - fireEvent.click(screen.getByRole('link', { name: '参考资料' })); - expect(openLearningExternalLinkMock).toHaveBeenCalledWith('https://example.com/guide'); - }); - - it('downloads only after the detail page action', async () => { - fetchLearningProjectMock.mockResolvedValue({ - ...project, - archiveFileName: 'robot-arm.zip', - archiveSha256: 'a'.repeat(64), - readmeMarkdown: '# 项目介绍', - }); - downloadLearningProjectMock.mockResolvedValue({ status: 'saved' }); - render( - - } /> - , - ); - - fireEvent.click(await screen.findByRole('button', { name: '下载项目' })); - await waitFor(() => expect(downloadLearningProjectMock).toHaveBeenCalledWith('robot-arm')); - }); -}); diff --git a/tests/unit/learning-project-download.test.ts b/tests/unit/learning-project-download.test.ts deleted file mode 100644 index 7745e41..0000000 --- a/tests/unit/learning-project-download.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createHash } from 'node:crypto'; -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - saveLearningProjectArchive, - safeLearningProjectArchiveFileName, -} from '@electron/services/learning-project-download'; - -const archive = Buffer.from([0x50, 0x4b, 0x03, 0x04, 1, 2, 3, 4]); -const archiveSha256 = createHash('sha256').update(archive).digest('hex'); -const binding = { accountKey: 'a'.repeat(64), epoch: 1 }; -let root = ''; - -function project(overrides: Record = {}) { - return { - id: 'project-1', - name: '机械臂', - summary: '项目介绍', - cover: { url: '/api/learning/projects/project-1/media/cover', alt: '封面' }, - tags: [], - version: null, - archiveBytes: archive.length, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-01T00:00:00Z', - readmeMarkdown: '# 机械臂', - archiveFileName: 'project.zip', - archiveSha256, - ...overrides, - } as never; -} - -describe('Learning project verified download', () => { - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'makelore-learning-project-')); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - it('streams, verifies and atomically saves a ZIP without returning its path', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) }, - })); - const destinationPath = join(root, '机械臂.zip'); - - await saveLearningProjectArchive({ - project: project(), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects/project-1/archive', - expect.objectContaining({ - headers: { Accept: 'application/zip', Authorization: 'Bearer works-token' }, - redirect: 'manual', - }), - ); - }); - - it('downloads without comparing metadata or transport-reported archive sizes', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length + 100) }, - })); - const destinationPath = join(root, 'large-project.zip'); - - await saveLearningProjectArchive({ - project: project({ archiveBytes: 512 * 1024 * 1024 + 1 }), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - }); - - it('downloads when the archive response omits Content-Length', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip' }, - })); - const destinationPath = join(root, 'unknown-size-project.zip'); - - await saveLearningProjectArchive({ - project: project({ archiveBytes: 1 }), - destinationPath, - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - }); - - await expect(readFile(destinationPath)).resolves.toEqual(archive); - }); - - it('rejects an unsafe redirect and never forwards Bearer credentials to redirects', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { - status: 302, - headers: { Location: 'https://storage.example/private.zip' }, - })); - - await expect(saveLearningProjectArchive({ - project: project(), - destinationPath: join(root, 'project.zip'), - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - })).rejects.toMatchObject({ code: 'LEARNING_DOWNLOAD_REDIRECT_INVALID' }); - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); - - it('removes partial files after an integrity failure', async () => { - const fetchImpl = vi.fn().mockResolvedValue(new Response(archive, { - status: 200, - headers: { 'Content-Type': 'application/zip', 'Content-Length': String(archive.length) }, - })); - - await expect(saveLearningProjectArchive({ - project: project({ archiveSha256: 'f'.repeat(64) }), - destinationPath: join(root, 'project.zip'), - binding, - fetchImpl, - getAccessToken: vi.fn().mockResolvedValue('works-token'), - isCurrentAccountBinding: () => true, - apiBaseUrl: 'https://square.example', - })).rejects.toMatchObject({ code: 'LEARNING_ARCHIVE_HASH_MISMATCH' }); - await expect(readdir(root)).resolves.toEqual([]); - }); - - it('normalizes untrusted archive names to a ZIP file name', () => { - expect(safeLearningProjectArchiveFileName('../bad.exe', 'project:1')).toBe('Makelore-project-1.zip'); - expect(safeLearningProjectArchiveFileName('机械臂.zip', 'project-1')).toBe('机械臂.zip'); - }); -}); diff --git a/tests/unit/learning-project-image.test.tsx b/tests/unit/learning-project-image.test.tsx deleted file mode 100644 index 6a9864c..0000000 --- a/tests/unit/learning-project-image.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { ProjectImage } from '@/pages/Learning/ProjectImage'; - -describe('Learning ProjectImage', () => { - it('renders a credential-free HTTPS image only when direct loading is allowed', () => { - const { rerender } = render( - , - ); - - expect(screen.getByAltText('项目结构图')).toHaveAttribute( - 'src', - 'https://images.example.com/diagram.svg', - ); - - rerender( - , - ); - - expect(screen.queryByAltText('项目结构图')).not.toBeInTheDocument(); - expect(screen.getByRole('img', { name: '项目结构图加载失败' })).toBeInTheDocument(); - }); -}); diff --git a/tests/unit/learning-route.test.ts b/tests/unit/learning-route.test.ts deleted file mode 100644 index 319b79c..0000000 --- a/tests/unit/learning-route.test.ts +++ /dev/null @@ -1,303 +0,0 @@ -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createLearningRouteHandler } from '@electron/api/routes/learning'; - -vi.mock('electron', () => ({ - app: { getPath: vi.fn(() => 'C:\\Downloads') }, - dialog: { showSaveDialog: vi.fn() }, -})); - -const ARCHIVE_HASH = 'a'.repeat(64); - -function project(overrides: Record = {}) { - return { - id: 'project-1', - name: '机械臂入门', - summary: '从零搭建桌面机械臂。', - cover: { - url: '/api/learning/projects/project-1/media/cover', - alt: '机械臂封面', - width: 1600, - height: 900, - internalKey: 'private/cover.webp', - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', - readmeMarkdown: '# 机械臂入门', - archiveFileName: 'robot-arm.zip', - archiveSha256: ARCHIVE_HASH, - objectStorageKey: 'private/project.zip', - ...overrides, - }; -} - -function envelope(data: unknown): Response { - return new Response(JSON.stringify({ success: true, data }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); -} - -function createResponse() { - const chunks: string[] = []; - const res = { - statusCode: 0, - setHeader: vi.fn(), - end: vi.fn((chunk?: string) => { - if (chunk) chunks.push(chunk); - }), - } as unknown as ServerResponse; - return { - res, - get json() { - return JSON.parse(chunks.join('')) as Record; - }, - }; -} - -describe('Learning project Main route boundary', () => { - const fetchImpl = vi.fn(); - const getAccessToken = vi.fn(); - - beforeEach(() => { - fetchImpl.mockReset(); - getAccessToken.mockReset(); - }); - - it.each([ - '/api/works/projects', - '/api/works/learning/courses', - '/api/works/learning/projects/project-1/unknown', - ])('does not claim old or unrelated route %s', async (pathname) => { - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await expect(handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL(`http://127.0.0.1${pathname}`), - {} as never, - )).resolves.toBe(false); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it('forwards bounded pagination and strictly projects project cards', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope({ - items: [project()], - nextCursor: 'next-page', - total: 1, - internalFacet: 'drop-me', - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example/' }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects?cursor=opaque&limit=24&unknown=no'), - {} as never, - ); - - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects?cursor=opaque&limit=24', - expect.objectContaining({ - method: 'GET', - headers: { Accept: 'application/json', Authorization: 'Bearer works-token' }, - redirect: 'manual', - }), - ); - expect(response.json).toEqual({ - success: true, - data: { - items: [{ - id: 'project-1', - name: '机械臂入门', - summary: '从零搭建桌面机械臂。', - cover: { - url: '/api/learning/projects/project-1/media/cover', - alt: '机械臂封面', - width: 1600, - height: 900, - }, - tags: ['机器人', 'Python'], - version: '1.2.0', - archiveBytes: 1024, - publishedAt: '2026-08-01T00:00:00Z', - updatedAt: '2026-08-18T00:00:00Z', - }], - nextCursor: 'next-page', - total: 1, - }, - }); - expect(JSON.stringify(response.json)).not.toContain('objectStorageKey'); - }); - - it('projects detail Markdown and archive integrity metadata without storage keys', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1'), - {} as never, - ); - - expect(response.json).toMatchObject({ - success: true, - data: { - id: 'project-1', - readmeMarkdown: '# 机械臂入门', - archiveFileName: 'robot-arm.zip', - archiveSha256: ARCHIVE_HASH, - }, - }); - expect(JSON.stringify(response.json)).not.toContain('private/project.zip'); - }); - - it('accepts project metadata above the former client archive-size limit', async () => { - getAccessToken.mockResolvedValue('works-token'); - const archiveBytes = 512 * 1024 * 1024 + 1; - fetchImpl.mockResolvedValue(envelope(project({ archiveBytes }))); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1'), - {} as never, - ); - - expect(response.json).toMatchObject({ - success: true, - data: { id: 'project-1', archiveBytes }, - }); - }); - - it('rejects malformed identities and unsafe media URLs in successful DTOs', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope({ - items: [project({ cover: { url: 'http://internal.example/cover.png', alt: '不安全' } })], - nextCursor: null, - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects'), - {} as never, - ); - - expect(response.res.statusCode).toBe(502); - expect(response.json).toMatchObject({ code: 'LEARNING_INVALID_RESPONSE' }); - }); - - it('proxies only fixed raster media paths with Works authentication', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(new Response(Uint8Array.from([1, 2, 3]), { - status: 200, - headers: { 'Content-Type': 'image/webp', 'Content-Length': '3' }, - })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken, apiBaseUrl: 'https://square.example' }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/media/readme-1'), - {} as never, - ); - - expect(fetchImpl).toHaveBeenCalledWith( - 'https://square.example/api/learning/projects/project-1/media/readme-1', - expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer works-token' }) }), - ); - expect(response.json).toEqual({ dataBase64: 'AQID', mimeType: 'image/webp' }); - }); - - it('uses project metadata and a native destination before delegating verified download', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const chooseDestination = vi.fn().mockResolvedValue('D:\\Downloads\\robot-arm.zip'); - const saveArchive = vi.fn().mockResolvedValue(undefined); - const binding = { accountKey: 'b'.repeat(64), epoch: 1 }; - const handler = createLearningRouteHandler({ - fetchImpl, - getAccessToken, - getAccountBinding: () => binding, - isCurrentAccountBinding: () => true, - chooseDestination, - saveArchive, - apiBaseUrl: 'https://square.example', - }); - const response = createResponse(); - - await handler( - { method: 'POST' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'), - { mainWindow: null } as never, - ); - - expect(chooseDestination).toHaveBeenCalledWith(expect.anything(), 'robot-arm.zip'); - expect(saveArchive).toHaveBeenCalledWith(expect.objectContaining({ - destinationPath: 'D:\\Downloads\\robot-arm.zip', - binding, - project: expect.objectContaining({ id: 'project-1', archiveSha256: ARCHIVE_HASH }), - })); - expect(response.json).toEqual({ success: true, data: { status: 'saved' } }); - }); - - it('does not download when the user cancels the native save dialog', async () => { - getAccessToken.mockResolvedValue('works-token'); - fetchImpl.mockResolvedValue(envelope(project())); - const saveArchive = vi.fn(); - const handler = createLearningRouteHandler({ - fetchImpl, - getAccessToken, - getAccountBinding: () => ({ accountKey: 'b'.repeat(64), epoch: 1 }), - isCurrentAccountBinding: () => true, - chooseDestination: vi.fn().mockResolvedValue(null), - saveArchive, - }); - const response = createResponse(); - - await handler( - { method: 'POST' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects/project-1/download'), - {} as never, - ); - - expect(saveArchive).not.toHaveBeenCalled(); - expect(response.json).toEqual({ success: true, data: { status: 'cancelled' } }); - }); - - it('refreshes one upstream 401 and redacts unavailable-service details', async () => { - getAccessToken.mockResolvedValueOnce('expired').mockResolvedValueOnce('fresh'); - fetchImpl - .mockResolvedValueOnce(new Response('expired secret', { status: 401 })) - .mockResolvedValueOnce(envelope({ items: [], nextCursor: null })); - const handler = createLearningRouteHandler({ fetchImpl, getAccessToken }); - const response = createResponse(); - - await handler( - { method: 'GET' } as IncomingMessage, - response.res, - new URL('http://127.0.0.1/api/works/learning/projects'), - {} as never, - ); - - expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); - expect(response.json).toEqual({ success: true, data: { items: [], nextCursor: null } }); - }); -}); diff --git a/tests/unit/learning-sidebar.test.tsx b/tests/unit/learning-sidebar.test.tsx deleted file mode 100644 index e28074b..0000000 --- a/tests/unit/learning-sidebar.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; -import { LearningSidebar } from '@/components/layout/LearningSidebar'; - -function LocationProbe() { - const location = useLocation(); - return {location.pathname}; -} - -describe('LearningSidebar', () => { - it('shows only the new project catalog navigation and returns detail pages to the list', () => { - render( - - - } /> - - , - ); - - expect(screen.getByRole('navigation', { name: 'AI 学习导航' })).toHaveTextContent('学习项目'); - expect(screen.queryByText('生成课程')).not.toBeInTheDocument(); - expect(screen.queryByText('我的课程')).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '学习项目' })); - expect(screen.getByTestId('location-path')).toHaveTextContent('/learning'); - }); - - it('keeps the collapsed navigation compact', () => { - render(); - expect(screen.getByRole('button', { name: '学习项目' })).toBeInTheDocument(); - expect(screen.queryByText('阅读项目说明并下载到电脑实践。')).not.toBeInTheDocument(); - }); -}); diff --git a/tests/unit/main-layout-module-gate.test.tsx b/tests/unit/main-layout-module-gate.test.tsx index 827d9c7..37bb229 100644 --- a/tests/unit/main-layout-module-gate.test.tsx +++ b/tests/unit/main-layout-module-gate.test.tsx @@ -7,7 +7,7 @@ import { useProjectConfigStore } from '@/stores/project-config'; import { useSettingsStore } from '@/stores/settings'; import { createCodingProjectConfigV2 } from '@electron/coding-projects/project-config'; -const learningIpcMock = vi.hoisted(() => vi.fn()); +const routeRenderMock = vi.hoisted(() => vi.fn()); vi.mock('@/components/layout/Sidebar', () => ({ Sidebar: ({ sidebarCollapsedOverride }: { sidebarCollapsedOverride?: boolean }) => ( @@ -26,8 +26,8 @@ vi.mock('@/components/layout/TitleBar', () => ({ ), })); -function LearningRouteContent() { - learningIpcMock(); +function RouteContent() { + routeRenderMock(); return
; } @@ -36,7 +36,7 @@ function renderLayout(path: string) { }> - } /> + } /> , @@ -45,7 +45,7 @@ function renderLayout(path: string) { describe('MainLayout module isolation', () => { beforeEach(() => { - learningIpcMock.mockReset(); + routeRenderMock.mockReset(); const project = { id: 'local-project', path: '/tmp/local-project', @@ -118,16 +118,4 @@ describe('MainLayout module isolation', () => { expect(screen.getByTestId('main-content')).toHaveClass('basis-0', 'overflow-hidden', 'p-0', 'sm:p-6'); }); - it.each(['/learning', '/learning/project/project-1'])( - 'keeps the project catalog route %s in the standard integrated shell', - (path) => { - renderLayout(path); - - expect(screen.getByTestId('sidebar-stub')).toBeVisible(); - expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-integrated', 'true'); - expect(screen.getByTestId('main-content')).toHaveClass('p-5', 'sm:p-6'); - expect(screen.getByTestId('route-content')).toBeVisible(); - expect(screen.queryByTestId('learning-profile-sync-error-gate')).not.toBeInTheDocument(); - }, - ); }); diff --git a/tests/unit/module-access.test.ts b/tests/unit/module-access.test.ts new file mode 100644 index 0000000..d4bed59 --- /dev/null +++ b/tests/unit/module-access.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_MODULE_ACCESS, + MODULE_ACCESS_KEYS, + normalizeModuleAccess, +} from '../../shared/module-access'; + +describe('module access projection', () => { + it('contains only the three supported product modules', () => { + expect(MODULE_ACCESS_KEYS).toEqual(['programming', 'design', 'robot']); + expect(DEFAULT_MODULE_ACCESS).toEqual({ + programming: true, + design: true, + robot: true, + }); + }); + + it('drops the retired Learning key from legacy or upstream policy data', () => { + const access = normalizeModuleAccess({ + programming: false, + design: true, + learning: false, + robot: false, + }); + + expect(access).toEqual({ + programming: false, + design: true, + robot: false, + }); + expect(access).not.toHaveProperty('learning'); + }); +}); diff --git a/tests/unit/module-navigation.test.tsx b/tests/unit/module-navigation.test.tsx index f2d4e44..6db5c21 100644 --- a/tests/unit/module-navigation.test.tsx +++ b/tests/unit/module-navigation.test.tsx @@ -52,7 +52,7 @@ describe('AI module navigation', () => { }); }); - it('shows the four module choices and routes Canvas from the chooser', async () => { + it('shows only the three supported module choices and routes Canvas from the chooser', async () => { render( @@ -68,9 +68,11 @@ describe('AI module navigation', () => { expect(screen.getByTestId('ai-module-options')).toHaveClass('module-selection-options'); expect(await screen.findByText('小明,欢迎回来!')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-programming')).toHaveClass('module-option-card', 'module-option-card-horizontal'); - for (const moduleId of ['programming', 'painting', 'learning', 'robot'] as const) { + for (const moduleId of ['programming', 'painting', 'robot'] as const) { expect(screen.getByTestId(`ai-module-option-${moduleId}`).querySelector('.module-option-artwork')).toBeNull(); } + expect(screen.getAllByTestId(/^ai-module-option-/u)).toHaveLength(3); + expect(screen.queryByTestId('ai-module-option-learning')).not.toBeInTheDocument(); expect(screen.queryByText('先选一个入口。进入后,你还可以从左下角随时切换到其他模块。')).not.toBeInTheDocument(); expect(screen.queryByText('选择创作空间。你可以随时从侧边栏切换。')).not.toBeInTheDocument(); expect(screen.getByAltText('Makelore')).toHaveClass('h-8', 'w-40', 'object-cover'); @@ -82,12 +84,6 @@ describe('AI module navigation', () => { expect(screen.getByText('Canvas 设计')).toBeInTheDocument(); expect(screen.getByAltText('数位板设计创作')).toBeInTheDocument(); expect(screen.getByTestId('ai-module-option-painting')).toHaveTextContent('用灵感描绘色彩缤纷的世界'); - const learningOption = screen.getByTestId('ai-module-option-learning'); - expect(learningOption).toBeEnabled(); - expect(learningOption).not.toHaveClass('module-option-card-disabled'); - expect(learningOption).toHaveTextContent('Learning 学习'); - expect(learningOption).toHaveTextContent('用顶尖的方法解锁万物规律'); - expect(screen.getByAltText('数学科技宇宙')).toBeInTheDocument(); const robotOption = screen.getByTestId('ai-module-option-robot'); expect(robotOption).toBeEnabled(); expect(robotOption).not.toHaveClass('module-option-card-disabled'); @@ -95,8 +91,8 @@ describe('AI module navigation', () => { expect(robotOption).toHaveTextContent('制作你的第一个机器人小伙伴'); expect(screen.getByAltText('青少年管理机器人硬件与设备绑定')).toBeInTheDocument(); - fireEvent.click(learningOption); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/learning'); + fireEvent.click(screen.getByTestId('ai-module-option-painting')); + expect(await screen.findByTestId('location-path')).toHaveTextContent('/image-canvas'); }); it('routes Robot from the chooser to the AI hardware workspace', async () => { @@ -117,8 +113,7 @@ describe('AI module navigation', () => { useAuthStore.setState({ moduleAccess: { programming: true, - design: true, - learning: false, + design: false, robot: true, }, user: { @@ -139,12 +134,12 @@ describe('AI module navigation', () => { , ); - const learningOption = screen.getByTestId('ai-module-option-learning'); - expect(learningOption).toBeDisabled(); - expect(learningOption).toHaveClass('module-option-card-disabled'); - expect(learningOption).toHaveAttribute('aria-disabled', 'true'); + const canvasOption = screen.getByTestId('ai-module-option-painting'); + expect(canvasOption).toBeDisabled(); + expect(canvasOption).toHaveClass('module-option-card-disabled'); + expect(canvasOption).toHaveAttribute('aria-disabled', 'true'); - fireEvent.click(learningOption); + fireEvent.click(canvasOption); expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument(); expect(screen.queryByTestId('location-path')).not.toBeInTheDocument(); }); @@ -274,20 +269,6 @@ describe('AI module navigation', () => { expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select'); }); - it('shows Learning as the active module and returns to the chooser from the learning route', async () => { - render( - - - } /> - - , - ); - - expect(screen.getByTestId('sidebar-module-switcher-trigger')).toHaveTextContent('学习 Learning'); - fireEvent.click(screen.getByTestId('sidebar-module-switcher-trigger')); - expect(await screen.findByTestId('location-path')).toHaveTextContent('/module-select'); - }); - it('keeps breathing room around the compact module switcher trigger', () => { render(