diff --git a/plugins/system-manager/CHANGELOG.md b/plugins/system-manager/CHANGELOG.md index 832cdd97..271bccb1 100644 --- a/plugins/system-manager/CHANGELOG.md +++ b/plugins/system-manager/CHANGELOG.md @@ -1,5 +1,20 @@ # 更新日志 +## 0.2.1 - 2026-08-03 + +### 修复 + +- 监听 ZTools 插件退出生命周期,运行中升级时拒绝新的副作用请求,并等待当前 Agent journal 在有限窗口内收敛。 +- 为应用卸载与启动项变更增加受限、原子写入的本地恢复记录;重新进入时核对未完成批次,不自动重试系统写操作。 +- 恢复跨会话启动项撤销记录到根 MCP runtime,确保新会话仍可安全调用 `undo_startup_change`。 +- 将 Linux CNI、容器、虚拟化和 VPN 接口识别为受限网络接口;无法确认类型的非典型接口默认要求用户确认。 +- 要求 Agent 在 `prepare_lan_scan` 中显式确认受限接口,防止执行阶段绕过虚拟/VPN/未知接口的二次确认。 + +### 优化 + +- 接入 ZTools 3.0.2 注入的 `--plugin-primary-color`,保持警告、危险和成功状态颜色的语义独立。 +- 补充运行中升级、恢复记录、虚拟接口确认和主题色变量测试与安全说明。 + ## 0.2.0 - 2026-07-31 ### 新增 diff --git a/plugins/system-manager/README.md b/plugins/system-manager/README.md index d6b9f781..964bbedf 100644 --- a/plugins/system-manager/README.md +++ b/plugins/system-manager/README.md @@ -14,6 +14,8 @@ 五个 Feature 均显式支持 macOS、Windows 和 Linux。实际可见项目和系统命令仍受当前平台、权限与桌面环境影响。 +插件升级或退出时会先进入关闭状态:新的 Agent 副作用请求会返回 `SHUTTING_DOWN`,已开始的请求最多等待 200ms;应用卸载和启动项修改会把中断进度写入受限恢复记录,下一次进入只核对状态,不会自动重试。垃圾清理仍只把项目移入可恢复的系统废纸篓,局域网扫描有 12 秒上限且可取消,系统诊断 helper 也有独立超时与进程树清理;升级边界之外若已有底层系统调用,结果以模块返回值或恢复记录为准。 + ## 单插件结构 ```text @@ -24,7 +26,7 @@ system-manager/ ├── tests/ # workspace、路由和最终发布物整合测试 ├── CHANGELOG.md # 面向插件升级审核的版本更新记录 ├── dist/ # 单一 ZTools 发布目录(构建生成) -└── release/system-manager-0.2.0.zip +└── release/system-manager-0.2.1.zip ``` 最终 `dist` 只保留根 `plugin.json`。模块发布物位于 `dist/modules//`,其嵌套 manifest 会在组装时删除。每个模块 HTML 都会注入位于文档流首部的统一 SuiteBar,共用 `_system-manager/navigation.js` 和 `_system-manager/navigation.css`。 @@ -44,6 +46,8 @@ window.systemManagerAgentAccess.revoke() Agent 工具要求 **ZTools 2.4 或更高版本**。宿主从 `plugin.json.tools` 读取声明,通过根 preload 的 `window.ztools.registerTool(name, handler)` 同步注册;对外名称由宿主自动加上 `system_manager_` 前缀。旧版宿主会安全跳过工具注册,图形界面与模块路由仍可使用。冷启动只需加载根 preload;22 个 handler 会在宿主的 5 秒等待窗口内完成注册,五个业务模块直到对应工具或页面实际使用时才按需加载。 +已针对 **ZTools 3.0.2** 验证:主题色变量动态注入、单一 `onPluginOut` 生命周期回调、插件运行中升级,以及更新入口不会改变现有 MCP 注册和页面路由。3.0.2 目前没有要求系统管家迁移到新的用户资料、文件剪贴板或截图参数 API;这些能力仍按插件现有边界保持未使用。 + 工具默认是只读的:能力查询、诊断采集/渲染、应用/启动项/垃圾清单及局域网接口查询均不需要授权。产生写入或主动网络流量前,用户必须在系统管家 dashboard 明确授予以下一个或多个 scope: | Scope | 允许的副作用 | @@ -54,7 +58,7 @@ Agent 工具要求 **ZTools 2.4 或更高版本**。宿主从 `plugin.json.tools | `system_cleanup` | 仅将白名单内、复验通过且属于当前用户的垃圾项目移入废纸篓 | | `lan_scan` | 在选定接口上发送有界 ICMP;最多每 15 秒启动一次,可选 DNS 解析默认关闭,不做端口、公网或漏洞扫描 | -scope 授权最长 10 分钟,可随时撤销,并且只保存在当前 dashboard renderer 的内存中;离开或刷新 dashboard、重启插件都会立即失效。应用处理、新的启动项启停、垃圾清理与 LAN 扫描还必须先调用对应 `prepare_*` 工具取得带摘要与摘要指纹的单次 `actionId`;action 90 秒后失效,执行时会再次复验。启动项撤销则只接受最近一次成功变更返回且仍有效的 `operationId`。副作用调用要求 8–128 字符的 `idempotencyKey`;当前 renderer 会话内最近 10 分钟最多保留 100 条执行记录,可用 `get_operation_result` 查询且不会重放操作。`get_capabilities` 与查询结果都会返回 `runtimeSessionId`;刷新、导航或重启后该值变化,旧会话的操作结果与撤销元数据无法恢复。授权不足或 LAN 限速这类 action 消费前失败不会占用幂等键,action 一旦消费,其成功或失败结果都会保持稳定。 +scope 授权最长 10 分钟,可随时撤销,并且只保存在当前 dashboard renderer 的内存中;离开或刷新 dashboard、重启插件都会立即失效。应用处理、新的启动项启停、垃圾清理与 LAN 扫描还必须先调用对应 `prepare_*` 工具取得带摘要与摘要指纹的单次 `actionId`;action 90 秒后失效,执行时会再次复验。启动项撤销则只接受最近一次成功变更返回且仍有效的 `operationId`。副作用调用要求 8–128 字符的 `idempotencyKey`;当前 renderer 会话内最近 10 分钟最多保留 100 条执行记录,可用 `get_operation_result` 查询且不会重放操作。`get_capabilities` 与查询结果都会返回 `runtimeSessionId`;根 MCP 结果仍是会话内存数据,但启动项 rollback 与应用卸载逐项 operation/result 会在 preload 私有 journal 中短暂持久化并在新会话 reconcile/核对。授权不足或 LAN 限速这类 action 消费前失败不会占用幂等键,action 一旦消费,其成功或失败结果都会保持稳定。 ZTools 的 `registerTool` 当前不提供逐调用宿主审批提示,因此系统管家不把注册本身当作用户同意。安全补偿包括:dashboard 短时 scope、默认只读、90 秒预览 action、执行前复验、幂等日志、有界缓存/分页/数组、固定平台 allowlist、错误脱敏,以及应用处理和清理仅移入可人工恢复的废纸篓。`copyText`、`revealPath`/`reveal` 和 `cancelScan` 仍是 UI-only 辅助能力,不注册为 Agent 业务工具。 diff --git a/plugins/system-manager/SECURITY.md b/plugins/system-manager/SECURITY.md index 9886dec6..b248a9b0 100644 --- a/plugins/system-manager/SECURITY.md +++ b/plugins/system-manager/SECURITY.md @@ -13,6 +13,7 @@ - 开机启动:只修改 Linux/Windows 上明确支持且可回滚的用户级项目;macOS、系统级与未知来源项目保持只读。扫描、修改和撤销全局串行,避免跨项目并发放大本地校验或子进程调用。 - 垃圾清理:只扫描白名单缓存、日志和临时目录;近期项目仍可预览但默认不选择,符号链接、其他用户内容、系统目录或无法完整复验的项目会安全跳过。 - 局域网发现:只读取邻居表并发送有界 ICMP Echo;禁止端口扫描、服务识别、漏洞探测和公网扫描。 +- 升级/退出生命周期:ZTools 3.0.2 的单一 `onPluginOut` 回调由套件根 preload 统一协调;进入关闭状态后拒绝新的 Agent 写入,最多等待 200ms。应用卸载与启动项修改使用带 TTL 的原子恢复记录并在新会话中只核对不重试;垃圾清理的废纸篓移动、LAN 的可取消限时扫描和诊断 helper 的进程树清理分别按模块边界收敛,升级中断后需以页面结果或恢复记录为准。 ## Agent / MCP 边界 @@ -22,7 +23,7 @@ Agent 工具要求 ZTools 2.4+。根 preload 在冷启动时同步注册 manifes 默认授权状态为关闭,所有读取工具保持只读。有副作用能力被拆成五个独立 scope:`report_export`、`application_removal`、`startup_changes`、`system_cleanup`、`lan_scan`。只能由用户在系统管家 dashboard 授予,最长 10 分钟且可立即撤销;授权只保存在当前 dashboard renderer 的内存中,不写入 `dbStorage` 或业务结果。离开或刷新 dashboard、renderer 重建和插件重启都会立即清除授权,任何调用都不会延长过期时间。 -应用处理、新的启动项启停、垃圾清理和 LAN 扫描使用 prepare/execute 两阶段协议。prepare 返回绑定规范化参数的 `actionId`、`actionDigest`、摘要与过期时间,90 秒后失效且单次消费;execute 重新检查 scope、action、快照及底层文件/接口状态。启动项撤销只接受最近一次成功变更返回且仍有效的 `operationId`。副作用调用使用 8–128 字符 `idempotencyKey`;当前 renderer 会话内最多 100 条结果保留 10 分钟,重复键不会重放操作,`get_operation_result` 仅查询既有状态。`runtimeSessionId` 用于识别刷新、导航或重启造成的会话重建;旧会话 journal 与撤销元数据不会持久化,也无法恢复。授权不足或 LAN 限速发生在 action 消费前时不保留临时 journal 记录,条件恢复后可用同键重试;action 一旦消费,可能已产生副作用的成功或失败结果都会固定保留。诊断报告最多缓存 3 份、5 分钟。 +应用处理、新的启动项启停、垃圾清理和 LAN 扫描使用 prepare/execute 两阶段协议。prepare 返回绑定规范化参数的 `actionId`、`actionDigest`、摘要与过期时间,90 秒后失效且单次消费;execute 重新检查 scope、action、快照及底层文件/接口状态。启动项撤销只接受最近一次成功变更返回且仍有效的 `operationId`。副作用调用使用 8–128 字符 `idempotencyKey`;当前 renderer 会话内最多 100 条结果保留 10 分钟,重复键不会重放操作,`get_operation_result` 仅查询既有状态。`runtimeSessionId` 用于识别刷新、导航或重启造成的会话重建;根 MCP journal 仍为会话内存数据。启动项 preload 会在写入前将单条 rollback journal 原子保存到隔离 `dbStorage`(无该能力时使用用户目录受限文件),新会话扫描时按权威状态 reconcile,凭据 10 分钟后清理;应用卸载则逐项保存 operation/result,最多保留一条且 10 分钟后清理,重启后只报告需核对状态,不会自动重试。授权不足或 LAN 限速发生在 action 消费前时不保留临时 journal 记录,条件恢复后可用同键重试;action 一旦消费,可能已产生副作用的成功或失败结果都会固定保留。诊断报告最多缓存 3 份、5 分钟。 具体副作用仍受模块边界约束:导出只接受缓存 `reportId` 并打开系统保存对话框,不接收任意路径或正文;应用/残留与垃圾项目仅移入可人工恢复的系统废纸篓;启动项只操作受支持的用户项并可能立即启停用户服务;LAN 只对已复验接口执行最多本机 /24 的 ICMP、最多每 15 秒启动一次,DNS 默认关闭。剪贴板复制、路径 reveal 和扫描 cancel 是 UI-only 辅助接口,不注册为 Agent 业务工具。 diff --git a/plugins/system-manager/modules/application-uninstaller/README.md b/plugins/system-manager/modules/application-uninstaller/README.md index d6aca792..7efb775c 100644 --- a/plugins/system-manager/modules/application-uninstaller/README.md +++ b/plugins/system-manager/modules/application-uninstaller/README.md @@ -23,7 +23,7 @@ Windows 不使用 `Win32_Product`,避免它触发 MSI 自修复;注册表扫 7. 目录使用 `opendir` 流式生成递归内容摘要,并限制 50,000 项、64 层、5 秒和同一设备;任一限制触发即不可自动删除。预览后任意深度的新建、删除或元数据变化都会阻止整体处理。 8. 符号链接、根目录、Home 本身、系统目录、共享容器和越界路径均拒绝自动处理。 9. 文件优先进入系统废纸篓/回收站,不使用 `rm -rf` 或等价命令。 -10. preload 只暴露 `scanApps`、`inspectApp`、`executePlan`、`revealPath` 四个业务接口;扫描异常通过结构化 warning 显示,不伪装成“0 个应用”。 +10. preload 只暴露 `scanApps`、`inspectApp`、`executePlan`、`revealPath` 四个业务接口及生命周期用的 `shutdown`;扫描异常通过结构化 warning 显示,不伪装成“0 个应用”。 11. macOS 与 Linux 应用枚举共享约 15 秒总预算;每次目录读取、元数据读取和外部解析都只使用剩余预算,超时返回已完成的部分结果及 warning。 12. 用户可写的 `Info.plist` 与 `.desktop` 必须是非符号链接普通文件且不超过 1 MiB;两者都通过文件句柄有界读取并检测读取期变化。macOS `plutil` 只解析已捕获的 stdin 字节,不再按路径二次打开;保留到应用目录中的字段会截断并强制复制,避免短字符串继续引用超大元数据缓冲区。 diff --git a/plugins/system-manager/modules/application-uninstaller/public/preload/core/engine.cjs b/plugins/system-manager/modules/application-uninstaller/public/preload/core/engine.cjs index e8fefc54..418e5e2d 100644 --- a/plugins/system-manager/modules/application-uninstaller/public/preload/core/engine.cjs +++ b/plugins/system-manager/modules/application-uninstaller/public/preload/core/engine.cjs @@ -3,6 +3,7 @@ const crypto = require('node:crypto') const fs = require('node:fs/promises') const os = require('node:os') +const path = require('node:path') const { opaqueId, sameFingerprint, secureFingerprint } = require('./safety.cjs') const { runFile } = require('./runner.cjs') const darwin = require('../platform/darwin.cjs') @@ -12,6 +13,9 @@ const linux = require('../platform/linux.cjs') const DEFAULT_PLAN_TTL_MS = 2 * 60 * 1000 const MAX_APPS = 5000 const MAX_CANDIDATES = 200 +const JOURNAL_VERSION = 1 +const JOURNAL_FILE = '.ztools/system-manager/application-uninstall.json' +const JOURNAL_TTL_MS = 10 * 60 * 1000 function publicApp(app) { return { @@ -29,6 +33,47 @@ function publicCandidate(candidate) { } } +function createJournalStore(options, home, fileSystem) { + const storage = options.storage || options.journalStore + const getStored = storage && (storage.get || storage.getItem) + const setStored = storage && (storage.set || storage.setItem) + const removeStored = storage && (storage.remove || storage.removeItem) + const filePath = options.journalPath || path.join(home, JOURNAL_FILE) + async function read() { + if (typeof getStored === 'function') { + const value = await getStored.call(storage, 'application-uninstall-v1') + if (!value) return null + return typeof value === 'string' ? JSON.parse(value) : value + } + try { return JSON.parse(String(await fileSystem.readFile(filePath, 'utf8'))) } catch { return null } + } + async function write(value) { + const serialized = JSON.stringify(value) + if (typeof setStored === 'function') { await setStored.call(storage, 'application-uninstall-v1', serialized); return } + const directory = path.dirname(filePath) + if (typeof fileSystem.mkdir === 'function') await fileSystem.mkdir(directory, { recursive: true, mode: 0o700 }) + const temp = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}` + try { + await fileSystem.writeFile(temp, serialized, { encoding: 'utf8', mode: 0o600, flag: 'wx' }) + if (typeof fileSystem.rename === 'function') await fileSystem.rename(temp, filePath) + else await fileSystem.writeFile(filePath, serialized, { encoding: 'utf8', mode: 0o600 }) + } finally { try { if (typeof fileSystem.unlink === 'function') await fileSystem.unlink(temp) } catch {} } + } + async function clear() { + if (storage) { + if (typeof removeStored === 'function') await removeStored.call(storage, 'application-uninstall-v1') + else if (typeof setStored === 'function') await setStored.call(storage, 'application-uninstall-v1', null) + return + } + try { await fileSystem.unlink(filePath) } catch {} + } + return Object.freeze({ read, write, clear, filePath }) +} + +function serializable(value) { + try { return JSON.parse(JSON.stringify(value)) } catch { return null } +} + function createEngine(options = {}) { const platform = options.platform || process.platform const adapter = options.adapter || ({ darwin, win32, linux }[platform]) @@ -46,11 +91,15 @@ function createEngine(options = {}) { now: options.now || (() => Date.now()), secret: options.secret || crypto.randomBytes(24).toString('hex'), } + const journalStore = createJournalStore(options, deps.home, deps.fs) const apps = new Map() const plans = new Map() let scanFlight = null let inspectFlight = null let activeAction = null + let shuttingDown = false + let journalLoaded = false + let persistedJournal = null function actionBusyError() { const error = new Error(`系统管家正在执行${activeAction || '其他操作'},请稍后重试`) @@ -59,11 +108,61 @@ function createEngine(options = {}) { } async function withAction(kind, task) { + if (shuttingDown && kind === '卸载处理') { + const error = new Error('插件正在退出,暂不接受新的卸载写入') + error.code = 'SHUTTING_DOWN' + throw error + } if (activeAction) throw actionBusyError() activeAction = kind try { return await task() } finally { activeAction = null } } + async function loadJournal() { + if (journalLoaded) return persistedJournal + journalLoaded = true + const value = await journalStore.read() + if (value && value.version === JOURNAL_VERSION && value.operationId) persistedJournal = value + return persistedJournal + } + + async function saveJournal(value) { + persistedJournal = value + await journalStore.write(value) + } + + async function reconcileJournal(warnings) { + const journal = await loadJournal() + if (!journal) return + const startedAt = Date.parse(journal.startedAt || '') + const retainedAt = Date.parse(journal.completedAt || journal.updatedAt || journal.startedAt || '') + if (!Number.isFinite(startedAt) || (Number.isFinite(retainedAt) && deps.now() - retainedAt > JOURNAL_TTL_MS)) { + await journalStore.clear() + persistedJournal = null + warnings.push('上次卸载记录已过期,已清理;请重新扫描后操作') + return + } + if (journal.status === 'completed') return + const unresolved = [] + for (const entry of Array.isArray(journal.entries) ? journal.entries : []) { + if (entry.status === 'pending') { unresolved.push(entry); continue } + if (entry.status !== 'in-progress') continue + try { + await deps.fs.lstat(entry.path) + unresolved.push(entry) + } catch (error) { + if (error && error.code === 'ENOENT') entry.status = 'trashed' + else unresolved.push(entry) + } + } + journal.status = unresolved.length ? 'recovery-required' : 'recovered' + journal.reconciledAt = new Date(deps.now()).toISOString() + await saveJournal(journal) + warnings.push(unresolved.length + ? `检测到上次卸载未完成,${unresolved.length} 个项目仍需核对;未自动重试` + : '已核对上次卸载记录,所有已开始项目均不在原位置') + } + async function scanApps() { if (scanFlight) return scanFlight scanFlight = withAction('应用扫描', async () => { @@ -88,6 +187,7 @@ function createEngine(options = {}) { const warnings = Array.isArray(sourceWarnings) ? sourceWarnings.filter((item) => typeof item === 'string').slice(0, 20) : [] if (source.length >= MAX_APPS) warnings.push('应用数量已达到安全上限') if (duplicateIds) warnings.push('检测到重复或无效的应用标识,相关条目已忽略') + await reconcileJournal(warnings) return { platform, scannedAt: new Date(deps.now()).toISOString(), apps: found.map(publicApp), warnings } }) try { return await scanFlight } finally { scanFlight = null } @@ -163,40 +263,76 @@ function createEngine(options = {}) { for (const id of selected) if (!allowed.has(id)) throw new Error('候选项不属于当前卸载计划') plan.used = true const results = [] + const operationId = crypto.randomUUID() + const journal = { + version: JOURNAL_VERSION, + operationId, + status: 'running', + planId: plan.id, + appName: plan.app.name, + startedAt: new Date(deps.now()).toISOString(), + entries: request.selectedIds.map((id) => { + const candidate = allowed.get(id) + return { candidateId: id, path: candidate.path, fingerprint: serializable(candidate.fingerprint), status: 'pending', result: null } + }), + } + // The journal is durable before the first external command/file move. + try { await saveJournal(journal) } catch (error) { plan.used = false; throw error } + const persistResult = async (candidateId, result) => { + const entry = journal.entries.find((value) => value.candidateId === candidateId) + if (entry) { entry.status = result.status; entry.result = serializable(result) } + journal.updatedAt = new Date(deps.now()).toISOString() + await saveJournal(journal) + } let uninstallFailed = false try { const uninstallResult = await runRegisteredUninstaller(plan.app) - if (uninstallResult) results.push(uninstallResult) + if (uninstallResult) { results.push(uninstallResult); await persistResult(uninstallResult.candidateId || 'uninstaller', uninstallResult) } } catch (error) { - results.push({ candidateId: 'uninstaller', status: 'failed', message: `卸载器执行失败:${error.message}` }) + const result = { candidateId: 'uninstaller', status: 'failed', message: `卸载器执行失败:${error.message}` } + results.push(result); journal.uninstaller = result; await saveJournal(journal) uninstallFailed = true } if (uninstallFailed) { - for (const id of request.selectedIds) results.push({ candidateId: id, status: 'skipped', message: '卸载器失败,未清理关联数据' }) - return { planId: plan.id, completedAt: new Date(deps.now()).toISOString(), results } + for (const id of request.selectedIds) { const result = { candidateId: id, status: 'skipped', message: '卸载器失败,未清理关联数据' }; results.push(result); await persistResult(id, result) } + journal.status = 'completed'; journal.completedAt = new Date(deps.now()).toISOString(); await saveJournal(journal) + return { planId: plan.id, completedAt: journal.completedAt, results } } for (const id of request.selectedIds) { const item = allowed.get(id) if (!item.deletable || !item.fingerprint) { - results.push({ candidateId: id, status: 'skipped', message: '此项目仅供预览,不能自动处理' }) + const result = { candidateId: id, status: 'skipped', message: '此项目仅供预览,不能自动处理' } + results.push(result); await persistResult(id, result) continue } + const entry = journal.entries.find((value) => value.candidateId === id) + if (entry) { entry.status = 'in-progress'; journal.status = 'in-progress'; journal.updatedAt = new Date(deps.now()).toISOString(); await saveJournal(journal) } try { const current = await secureFingerprint(item.path, deps, deps.fs) if (!sameFingerprint(item.fingerprint, current)) throw new Error('文件在预览后发生变化') await deps.trashItem(current.realPath) - results.push({ candidateId: id, status: 'trashed' }) + const result = { candidateId: id, status: 'trashed' } + results.push(result); await persistResult(id, result) } catch (error) { - results.push({ candidateId: id, status: 'failed', message: error.message }) + const result = { candidateId: id, status: 'failed', message: error.message } + results.push(result); await persistResult(id, result) } } - return { planId: plan.id, completedAt: new Date(deps.now()).toISOString(), results } + journal.status = 'completed'; journal.completedAt = new Date(deps.now()).toISOString(); await saveJournal(journal) + return { planId: plan.id, completedAt: journal.completedAt, results } } async function executePlan(request) { return withAction('卸载处理', () => executePlanUnlocked(request)) } + async function shutdown() { + shuttingDown = true + const started = Date.now() + while (activeAction === '卸载处理' && Date.now() - started < 200) await new Promise((resolve) => setTimeout(resolve, 5)) + return { state: 'shutdown', drained: activeAction !== '卸载处理' } + } + function revealPath(pathId) { if (typeof pathId !== 'string') return false for (const plan of plans.values()) { @@ -206,7 +342,7 @@ function createEngine(options = {}) { return false } - return { executePlan, inspectApp, revealPath, scanApps } + return { executePlan, inspectApp, revealPath, scanApps, shutdown, _state: { apps, plans, journalStore, get journal() { return persistedJournal }, get shuttingDown() { return shuttingDown } } } } -module.exports = { DEFAULT_PLAN_TTL_MS, createEngine, publicApp, publicCandidate } +module.exports = { DEFAULT_PLAN_TTL_MS, JOURNAL_FILE, JOURNAL_TTL_MS, JOURNAL_VERSION, createEngine, publicApp, publicCandidate } diff --git a/plugins/system-manager/modules/application-uninstaller/public/preload/services.cjs b/plugins/system-manager/modules/application-uninstaller/public/preload/services.cjs index d2d042f9..4bcc4de0 100644 --- a/plugins/system-manager/modules/application-uninstaller/public/preload/services.cjs +++ b/plugins/system-manager/modules/application-uninstaller/public/preload/services.cjs @@ -3,7 +3,18 @@ const { shell } = require('electron') const { createEngine } = require('./core/engine.cjs') +function hostStorage() { + const db = typeof window !== 'undefined' && window.ztools && window.ztools.dbStorage + if (!db || typeof db.getItem !== 'function' || typeof db.setItem !== 'function') return null + return { + get: (key) => db.getItem(key), + set: (key, value) => db.setItem(key, value), + remove: (key) => typeof db.removeItem === 'function' ? db.removeItem(key) : db.setItem(key, null), + } +} + const engine = createEngine({ + storage: hostStorage() || undefined, trashItem: (target) => shell.trashItem(target), revealItem: (target) => shell.showItemInFolder(target), }) @@ -13,4 +24,8 @@ window.applicationUninstaller = Object.freeze({ inspectApp: (appId) => engine.inspectApp(appId), executePlan: (request) => engine.executePlan(request), revealPath: (pathId) => engine.revealPath(pathId), + // Lifecycle is coordinated by the suite root because ZTools exposes one + // onPluginOut callback per renderer. Keep this internal method on the + // frozen bridge so the root can drain module writes before teardown. + shutdown: () => engine.shutdown(), }) diff --git a/plugins/system-manager/modules/application-uninstaller/src/styles.css b/plugins/system-manager/modules/application-uninstaller/src/styles.css index d7356cb7..03cd0e3d 100644 --- a/plugins/system-manager/modules/application-uninstaller/src/styles.css +++ b/plugins/system-manager/modules/application-uninstaller/src/styles.css @@ -13,7 +13,7 @@ --ink-faint: #5f6879; --line: #d5dbdc; --line-strong: #bac4c7; - --accent: #315f78; + --accent: var(--plugin-primary-color, #315f78); --accent-contrast: #ffffff; --accent-wash: #e1ebf0; --healthy: #39705b; @@ -39,7 +39,7 @@ --ink-faint: #98a5a8; --line: #333e42; --line-strong: #4a585c; - --accent: #83abc0; + --accent: var(--plugin-primary-color, #83abc0); --accent-contrast: #151b1e; --accent-wash: #243842; --healthy: #82b49e; @@ -842,7 +842,7 @@ p { --ink-faint: #98a5a8; --line: #333e42; --line-strong: #4a585c; - --accent: #83abc0; + --accent: var(--plugin-primary-color, #83abc0); --accent-contrast: #151b1e; --accent-wash: #243842; --healthy: #82b49e; diff --git a/plugins/system-manager/modules/application-uninstaller/tests/manifest.test.cjs b/plugins/system-manager/modules/application-uninstaller/tests/manifest.test.cjs index d48b4cc2..8e6991f9 100644 --- a/plugins/system-manager/modules/application-uninstaller/tests/manifest.test.cjs +++ b/plugins/system-manager/modules/application-uninstaller/tests/manifest.test.cjs @@ -21,13 +21,14 @@ test('manifest has stable identity, entries and three-platform feature', () => { } }) -test('preload exposes only the opaque uninstall bridge', () => { +test('preload exposes the opaque uninstall bridge plus lifecycle shutdown', () => { const source = fs.readFileSync(path.join(root, 'public', 'preload', 'services.cjs'), 'utf8') const match = source.match(/window\.applicationUninstaller\s*=\s*Object\.freeze\(\{([\s\S]*?)\}\)/) assert.ok(match) const methods = [...match[1].matchAll(/^\s+(\w+):/gm)].map((item) => item[1]) - assert.deepEqual(methods, ['scanApps', 'inspectApp', 'executePlan', 'revealPath']) + assert.deepEqual(methods, ['scanApps', 'inspectApp', 'executePlan', 'revealPath', 'shutdown']) assert.doesNotMatch(match[1], /\b(?:fs|exec|spawn|path)\b/) + assert.doesNotMatch(source, /\b(?:window\.)?ztools\.onPluginOut\s*\(/) }) test('manifest version matches package version', () => { diff --git a/plugins/system-manager/modules/application-uninstaller/tests/security.test.cjs b/plugins/system-manager/modules/application-uninstaller/tests/security.test.cjs index 5e5d95dc..9e94976c 100644 --- a/plugins/system-manager/modules/application-uninstaller/tests/security.test.cjs +++ b/plugins/system-manager/modules/application-uninstaller/tests/security.test.cjs @@ -4,7 +4,7 @@ const assert = require('node:assert/strict') const fs = require('node:fs/promises') const path = require('node:path') const test = require('node:test') -const { createEngine } = require('../public/preload/core/engine.cjs') +const { createEngine, JOURNAL_TTL_MS, JOURNAL_VERSION } = require('../public/preload/core/engine.cjs') const { assertCanonicalSafeUserPath, assertSafeUserPath, isInside, snapshotDirectory } = require('../public/preload/core/safety.cjs') const tempRoot = path.join(__dirname, '.tmp') @@ -303,6 +303,85 @@ test('only one execute action can revalidate or trash at a time', async (t) => { await first }) +test('uninstall journal records each in-progress item before moving it', async (t) => { + const { home, adapter } = await fixture(t) + const storage = new Map() + const journalStore = { + async get(key) { return storage.get(key) || null }, + async set(key, value) { storage.set(key, value) }, + async remove(key) { storage.delete(key) }, + } + let release + const gate = new Promise((resolve) => { release = resolve }) + const engine = createEngine({ platform: 'linux', home, adapter, storage: journalStore, secret: 'journal', trashItem: async () => gate }) + const scan = await engine.scanApps() + const plan = await engine.inspectApp(scan.apps[0].id) + const execution = engine.executePlan({ planId: plan.id, selectedIds: [plan.candidates[0].id], confirmation: 'Acme' }) + let journal + for (let attempt = 0; attempt < 20; attempt += 1) { + await new Promise((resolve) => setImmediate(resolve)) + journal = JSON.parse(storage.get('application-uninstall-v1')) + if (journal.entries[0].status === 'in-progress') break + } + assert.equal(journal.status, 'in-progress') + assert.equal(journal.entries[0].status, 'in-progress') + release() + await execution + const completed = JSON.parse(storage.get('application-uninstall-v1')) + assert.equal(completed.status, 'completed') +}) + +test('new session treats pending uninstall entries as recovery-required without retrying', async (t) => { + const { home, target, adapter } = await fixture(t) + const values = new Map([['application-uninstall-v1', JSON.stringify({ + version: JOURNAL_VERSION, + operationId: 'operation-pending', + status: 'running', + startedAt: new Date().toISOString(), + entries: [{ candidateId: 'candidate-pending', path: target, status: 'pending', result: null }], + })]]) + const storage = { + async get(key) { return values.get(key) || null }, + async set(key, value) { values.set(key, value) }, + async remove(key) { values.delete(key) }, + } + const engine = createEngine({ platform: 'linux', home, adapter, storage, secret: 'pending' }) + const scan = await engine.scanApps() + assert.match(scan.warnings.join(' '), /未完成.*未自动重试/) + const reconciled = JSON.parse(values.get('application-uninstall-v1')) + assert.equal(reconciled.status, 'recovery-required') +}) + +test('expired uninstall journal is removed during the next inventory scan', async (t) => { + const { home, adapter } = await fixture(t) + const values = new Map([['application-uninstall-v1', JSON.stringify({ + version: JOURNAL_VERSION, + operationId: 'operation-expired', + status: 'completed', + startedAt: new Date(Date.now() - JOURNAL_TTL_MS - 1).toISOString(), + completedAt: new Date(Date.now() - JOURNAL_TTL_MS - 1).toISOString(), + entries: [], + })]]) + const storage = { + async get(key) { return values.get(key) || null }, + async set(key, value) { values.set(key, value) }, + async remove(key) { values.delete(key) }, + } + const engine = createEngine({ platform: 'linux', home, adapter, storage, secret: 'expired' }) + const scan = await engine.scanApps() + assert.match(scan.warnings.join(' '), /过期.*清理/) + assert.equal(values.has('application-uninstall-v1'), false) +}) + +test('shutdown rejects a new uninstall write', async (t) => { + const { home, adapter } = await fixture(t) + const engine = createEngine({ platform: 'linux', home, adapter, secret: 'shutdown' }) + const scan = await engine.scanApps() + const plan = await engine.inspectApp(scan.apps[0].id) + await engine.shutdown() + await assert.rejects(engine.executePlan({ planId: plan.id, selectedIds: [plan.candidates[0].id], confirmation: 'Acme' }), (error) => error.code === 'SHUTTING_DOWN') +}) + test('duplicate adapter IDs are filtered before public rows can diverge from the inspect map', async () => { const base = { id: 'app_duplicate', platform: 'linux', version: null, publisher: null, appKey: 'duplicate', install: { kind: 'desktop', path: null, scope: 'user' }, uninstall: { mode: 'manual', requiresElevation: false, supported: false }, protected: false } const adapter = { diff --git a/plugins/system-manager/modules/lan-device-discovery/README.md b/plugins/system-manager/modules/lan-device-discovery/README.md index 4a40301f..e80929e2 100644 --- a/plugins/system-manager/modules/lan-device-discovery/README.md +++ b/plugins/system-manager/modules/lan-device-discovery/README.md @@ -10,7 +10,7 @@ - 展示 IP、可选反向解析名称、本地 OUI 厂商判断和在线状态。 - 最多探测 254 个地址;小于 `/24` 的大网段只处理本机所在的 `/24` 切片。 - 仅允许 RFC1918 私网与 `100.64.0.0/10` 共享地址网卡;不会扫描公网地址。 -- VPN、虚拟网卡、桥接网卡和 `100.64.0.0/10` 共享地址网卡默认不扫描,必须单独勾选二次确认。 +- VPN、虚拟网卡、桥接网卡和 `100.64.0.0/10` 共享地址网卡默认不扫描,必须单独勾选二次确认。Linux 容器/CNI 常见的 `cni0`、`flannel.1`、`podman0`、`docker0`、`br-*`、`veth*` 等名称会按虚拟接口处理;无法与常见物理网卡命名(如 `enp*`、`eth*`、`wlan*`、`Ethernet`)匹配的非典型名称也默认要求确认。 - ICMP 并发固定不超过 12,单地址等待约 700ms,总扫描时间最多 12 秒。 - 支持立即取消;取消会终止仍在运行的 ping 子进程并停止派发后续任务。 - 不进行端口扫描、服务识别、横向连接、漏洞检测或远程 HTTP 请求。 diff --git a/plugins/system-manager/modules/lan-device-discovery/public/preload/network/interfaces.cjs b/plugins/system-manager/modules/lan-device-discovery/public/preload/network/interfaces.cjs index d6fafee6..48a89008 100644 --- a/plugins/system-manager/modules/lan-device-discovery/public/preload/network/interfaces.cjs +++ b/plugins/system-manager/modules/lan-device-discovery/public/preload/network/interfaces.cjs @@ -59,20 +59,37 @@ function stableInterfaceId(name, address, prefixLength) { return crypto.createHash('sha256').update(`${name}\0${address}\0${prefixLength}`).digest('hex').slice(0, 16) } +// Keep the allow-list for automatically trusted physical names deliberately +// narrow. Linux distributions and Windows/macOS drivers use a predictable +// set of names, while a custom name must require the same confirmation as a +// known virtual adapter until we can identify it with confidence. +const TYPICAL_PHYSICAL_INTERFACE = /^(?:en\d+|en[opsx][\w-]+|eth\d+|wlan\d+|wl[\w-]+|ib\d+|usb\d+|lan\d+|wi-?fi(?:[\s_-].*)?|ethernet(?:[\s_-].*)?)$/i + +// These prefixes are emitted by common Linux container/CNI runtimes and +// desktop hypervisors. They are name-based hints only; all virtual and VPN +// matches still require an explicit scan confirmation below. +const VIRTUAL_INTERFACE = /^(?:bridge[\w.-]*|br(?:idge)?[-_.]?[\w.-]*|docker[\w.-]*|podman(?:\d+|[-_.][\w.-]+)?|cni(?:\d+|[-_.][\w.-]+)?|flannel(?:\d+|[-_.][\w.-]+)?|cali[\w.-]*|kube(?:[-_.][\w.-]+)?|veth[\w.-]*|virbr[\w.-]*|lxcbr[\w.-]*|lxdbr[\w.-]*|vmnet[\w.-]*|vbox[\w.-]*|vnet[\w.-]*|q(?:vb|vo|br|tr)[\w.-]*|hyper-v[\w.-]*|macvlan[\w.-]*|ipvlan[\w.-]*|dummy\d*|ifb\d*|weave[\w.-]*|vxlan[\w.-]*|ovs(?:[-_.][\w.-]+)?|bond\d*|team\d*|awdl\d*|p2p\d*|llw\d*)$/i + +const VPN_INTERFACE = /^(?:utun[\w.-]*|tun[\w.-]*|tap[\w.-]*|wg[\w.-]*|ppp[\w.-]*|ipsec[\w.-]*|tailscale[\w.-]*|zt[\w.-]*|ham[\w.-]*)$/i + function interfaceRank(name) { const value = String(name || '').toLowerCase() - if (/^(?:en\d+|eth\d+|wlan\d+|wi-?fi|ethernet)/.test(value)) return 0 - if (/^(?:bridge|utun|tun|tap|docker|veth|vmnet|vbox|hyper-v)/.test(value)) return 2 + if (interfaceKind(value) === 'physical' && isTypicalPhysicalInterface(value)) return 0 + if (interfaceKind(value) !== 'physical') return 2 return 1 } function interfaceKind(name) { - const value = String(name || '').toLowerCase() - if (/^(?:utun|tun|tap|wg|ppp|ipsec)/.test(value) || /vpn/.test(value)) return 'vpn' - if (/^(?:bridge|docker|veth|vmnet|vbox|virbr|br-|hyper-v)/.test(value)) return 'virtual' + const value = String(name || '').trim().toLowerCase() + if (VPN_INTERFACE.test(value) || /vpn/.test(value)) return 'vpn' + if (VIRTUAL_INTERFACE.test(value)) return 'virtual' return 'physical' } +function isTypicalPhysicalInterface(name) { + return TYPICAL_PHYSICAL_INTERFACE.test(String(name || '').trim()) +} + function exactInterfaceMatch(left, right) { return Boolean(left && right && left.id === right.id @@ -110,7 +127,11 @@ function listInterfacesFromNode(nodeOs) { // A directly assigned public address must never become an implicit sweep. if (scope === 'other') continue const kind = interfaceKind(name) - const requiresConfirmation = scope === 'shared' || kind !== 'physical' + const typicalPhysical = kind === 'physical' && isTypicalPhysicalInterface(name) + // A physical-looking but non-standard name is not enough evidence that + // this is a user LAN adapter. Keep confirmation-first behavior for + // custom Linux bridges, tunnel providers, and localized/driver names. + const requiresConfirmation = scope === 'shared' || kind !== 'physical' || !typicalPhysical result.push({ id: stableInterfaceId(name, address, prefixLength), name: String(name).slice(0, 120), @@ -126,7 +147,9 @@ function listInterfacesFromNode(nodeOs) { ? 'VPN 或隧道接口' : kind === 'virtual' ? '虚拟或桥接接口' - : null, + : !typicalPhysical + ? '无法确认接口类型,扫描前需确认' + : null, }) } } @@ -154,6 +177,7 @@ module.exports = { exactInterfaceMatch, interfaceKind, interfaceRank, + isTypicalPhysicalInterface, ipv4ToInt, isUsableUnicast, listInterfacesFromNode, diff --git a/plugins/system-manager/modules/lan-device-discovery/src/styles.css b/plugins/system-manager/modules/lan-device-discovery/src/styles.css index cf66c7db..fea067b6 100644 --- a/plugins/system-manager/modules/lan-device-discovery/src/styles.css +++ b/plugins/system-manager/modules/lan-device-discovery/src/styles.css @@ -6,14 +6,14 @@ --text: #202a2f; --text-muted: #5f6879; --border: #bac4c7; - --accent: #315f78; - --accent-strong: #244b63; + --accent: var(--plugin-primary-color, #315f78); + --accent-strong: var(--plugin-primary-color, #244b63); --accent-contrast: #ffffff; --success: #39705b; --warning: #8f5f18; --danger: #9a4642; --danger-contrast: #ffffff; - --focus: #315f78; + --focus: var(--plugin-primary-color, #315f78); --shadow: none; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif; color: var(--text); @@ -30,14 +30,14 @@ --text: #e2e8e9; --text-muted: #abb7ba; --border: #4a585c; - --accent: #83abc0; - --accent-strong: #a7c6d5; + --accent: var(--plugin-primary-color, #83abc0); + --accent-strong: var(--plugin-primary-color, #a7c6d5); --accent-contrast: #151b1e; --success: #82b49e; --warning: #d5a85f; --danger: #dc8d88; --danger-contrast: #151b1e; - --focus: #83abc0; + --focus: var(--plugin-primary-color, #83abc0); --shadow: none; } } diff --git a/plugins/system-manager/modules/lan-device-discovery/tests/network.test.cjs b/plugins/system-manager/modules/lan-device-discovery/tests/network.test.cjs index a0033bdf..1fe3469a 100644 --- a/plugins/system-manager/modules/lan-device-discovery/tests/network.test.cjs +++ b/plugins/system-manager/modules/lan-device-discovery/tests/network.test.cjs @@ -8,6 +8,7 @@ const test = require('node:test') const { addressScope, intToIPv4, + interfaceKind, ipv4ToInt, listInterfacesFromNode, prefixFromNetmask, @@ -62,6 +63,28 @@ test('interface inventory includes usable IPv4 only and creates stable opaque id assert.equal(values[0].cidr, '192.168.1.50/24') }) +test('Linux container and overlay interface names are restricted before scanning', () => { + assert.equal(interfaceKind('cni0'), 'virtual') + assert.equal(interfaceKind('flannel.1'), 'virtual') + assert.equal(interfaceKind('podman0'), 'virtual') + + const os = { networkInterfaces: () => ({ + cni0: [{ address: '10.244.0.1', family: 4, internal: false, netmask: '255.255.0.0' }], + 'flannel.1': [{ address: '10.245.0.1', family: 'IPv4', internal: false, netmask: '255.255.0.0' }], + podman0: [{ address: '10.88.0.1', family: 4, internal: false, netmask: '255.255.0.0' }], + custom0: [{ address: '192.168.44.1', family: 4, internal: false, netmask: '255.255.255.0' }], + }) } + const values = Object.fromEntries(listInterfacesFromNode(os).map((item) => [item.name, item])) + for (const name of ['cni0', 'flannel.1', 'podman0']) { + assert.equal(values[name].kind, 'virtual', name) + assert.equal(values[name].requiresConfirmation, true, name) + assert.equal(values[name].riskReason, '虚拟或桥接接口', name) + } + assert.equal(values.custom0.kind, 'physical') + assert.equal(values.custom0.requiresConfirmation, true) + assert.equal(values.custom0.riskReason, '无法确认接口类型,扫描前需确认') +}) + test('subnet candidates are bounded and broad networks are reduced to local /24', () => { const normal = generateCandidates(iface) assert.equal(normal.addresses.length, 253) diff --git a/plugins/system-manager/modules/startup-manager/README.md b/plugins/system-manager/modules/startup-manager/README.md index 2d3db310..e5886bf9 100644 --- a/plugins/system-manager/modules/startup-manager/README.md +++ b/plugins/system-manager/modules/startup-manager/README.md @@ -21,7 +21,7 @@ - Linux `.desktop` 同步维护 `Hidden` 与 `X-GNOME-Autostart-enabled`,使用同目录临时文件和原子替换;rename 前再次复核目标及父目录,撤销前再次校验内容。 - Windows Run 注册表值保持只读,避免插件崩溃后丢失恢复数据;计划任务由系统原生保存启用状态。 - 每次成功修改生成一次性 `operationId`;撤销记录 10 分钟后过期,且不会覆盖外部改动。 -- 新操作会使上一条 `operationId` 立即失效;内存中只保留 UI 可见的最新一条撤销记录。操作和撤销完成后均权威重扫启用与运行状态。 +- 新操作会使上一条 `operationId` 立即失效;最新一条撤销记录会在写入前保存到隔离 journal,并在新会话扫描时按权威状态恢复或标记需核对,10 分钟后自动清理。操作和撤销完成后均权威重扫启用与运行状态。 - 子进程只使用绝对系统工具路径、受控 `PATH` 和参数数组;PowerShell 使用静态 Base64 脚本和环境变量传值,严禁 shell 拼接。 - macOS 扫描以 `opendir` 流式读取、10 秒总时限和 8 路并发解析有界 plist;它只读取 launchd 状态用于展示,不执行 `enable`、`disable`、`bootstrap` 或 `bootout`。这是因为相同 Label 可能对应标准目录外动态加载的服务,现有公开信息不足以建立可信来源绑定。 - Windows 计划任务操作前后核对 XML(忽略 Enabled 位)、Principal、Actions 与 URI 指纹,定义变化即拒绝操作。 diff --git a/plugins/system-manager/modules/startup-manager/public/preload/core/manager.cjs b/plugins/system-manager/modules/startup-manager/public/preload/core/manager.cjs index 7bd34d5d..6a2b35a6 100644 --- a/plugins/system-manager/modules/startup-manager/public/preload/core/manager.cjs +++ b/plugins/system-manager/modules/startup-manager/public/preload/core/manager.cjs @@ -3,7 +3,9 @@ const crypto = require('node:crypto') const nodeFs = require('node:fs/promises') const os = require('node:os') +const path = require('node:path') const defaultRunner = require('./runner.cjs') +const { readState, stateEvidence } = require('./file-state.cjs') const { publicItem } = require('./model.cjs') const SNAPSHOT_TTL_MS = 10 * 60 * 1000 @@ -13,6 +15,8 @@ const MAX_SNAPSHOTS = 3 // limited to the one operation the user can still invoke. const MAX_OPERATIONS = 1 const EXCLUSIVE_LOCK = 'startup-manager-exclusive' +const JOURNAL_VERSION = 1 +const JOURNAL_FILE = '.ztools/system-manager/startup-rollback.json' function errorWithCode(message, code) { const error = new Error(message) @@ -20,20 +24,141 @@ function errorWithCode(message, code) { return error } +function createJournalStore(options, home, fileSystem) { + const storage = options.storage || options.journalStore + const getStored = storage && (storage.get || storage.getItem) + const setStored = storage && (storage.set || storage.setItem) + const removeStored = storage && (storage.remove || storage.removeItem) + const filePath = options.journalPath || path.join(home, JOURNAL_FILE) + async function read() { + if (typeof getStored === 'function') { + const value = await getStored.call(storage, 'startup-rollback-v1') + if (!value) return null + return typeof value === 'string' ? JSON.parse(value) : value + } + try { + const value = await fileSystem.readFile(filePath, 'utf8') + return JSON.parse(String(value)) + } catch { return null } + } + async function write(value) { + const serialized = JSON.stringify(value) + if (typeof setStored === 'function') { + await setStored.call(storage, 'startup-rollback-v1', serialized) + return + } + const directory = path.dirname(filePath) + if (typeof fileSystem.mkdir === 'function') await fileSystem.mkdir(directory, { recursive: true, mode: 0o700 }) + const temp = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}` + try { + await fileSystem.writeFile(temp, serialized, { encoding: 'utf8', mode: 0o600, flag: 'wx' }) + if (typeof fileSystem.rename === 'function') await fileSystem.rename(temp, filePath) + else await fileSystem.writeFile(filePath, serialized, { encoding: 'utf8', mode: 0o600 }) + } finally { try { if (typeof fileSystem.unlink === 'function') await fileSystem.unlink(temp) } catch {} } + } + async function clear() { + if (storage) { + if (typeof removeStored === 'function') await removeStored.call(storage, 'startup-rollback-v1') + else if (typeof setStored === 'function') await setStored.call(storage, 'startup-rollback-v1', null) + return + } + try { await fileSystem.unlink(filePath) } catch {} + } + return Object.freeze({ read, write, clear, filePath }) +} + +function serializable(value) { + try { return JSON.parse(JSON.stringify(value)) } catch { return null } +} + function createManager(options = {}) { const platform = options.platform || process.platform const adapter = options.adapter || require(`../adapters/${platform}.cjs`) - const deps = { runner: options.runner || defaultRunner, fs: options.fs || nodeFs, home: options.home || os.homedir(), uid: options.uid, powershell: options.powershell, locations: options.locations, desktopLocations: options.desktopLocations, toolPaths: options.toolPaths } + const home = options.home || os.homedir() + const deps = { runner: options.runner || defaultRunner, fs: options.fs || nodeFs, home, uid: options.uid, powershell: options.powershell, locations: options.locations, desktopLocations: options.desktopLocations, toolPaths: options.toolPaths } const clock = options.clock || Date.now + const journalStore = createJournalStore(options, home, deps.fs) const snapshots = new Map() const operations = new Map() const locks = new Map() let scanFlight = null + let journalLoaded = false + let persistedJournal = null + let shuttingDown = false function trim(map, limit) { while (map.size > limit) map.delete(map.keys().next().value) } + async function loadJournal() { + if (journalLoaded) return persistedJournal + journalLoaded = true + const value = await journalStore.read() + if (value && value.version === JOURNAL_VERSION && value.operationId && value.itemKey) persistedJournal = value + return persistedJournal + } + + async function saveJournal(value) { + persistedJournal = value + await journalStore.write(value) + } + + async function clearJournal() { + persistedJournal = null + await journalStore.clear() + } + + function stateMatches(item, enabled) { + return typeof item.enabled === 'boolean' && item.enabled === enabled + } + + async function reconcileJournal(items, warnings) { + const journal = await loadJournal() + if (!journal) return + const createdAt = Number.isFinite(journal.createdAt) ? journal.createdAt : Date.parse(journal.createdAt || '') + if (!Number.isFinite(createdAt) || clock() - createdAt > SNAPSHOT_TTL_MS) { + await clearJournal() + warnings.push('上次启动项撤销记录已过期,已清理;请重新扫描后操作') + return + } + const item = items.find((candidate) => candidate.key === journal.itemKey) + if (!item) { + warnings.push('检测到上次启动项变更记录,但当前扫描未找到对应项目;记录已保留以便后续核对') + return + } + if (stateMatches(item, journal.before && journal.before.enabled)) { + await clearJournal() + return + } + if (!stateMatches(item, journal.enabled)) { + warnings.push('上次启动项变更状态无法确认,请刷新后人工核对;撤销记录暂不可用') + return + } + let rollback = journal.rollback || null + // If the process stopped between the adapter mutation and the journal + // completion, reconstruct the minimal rollback proof from the pre-write + // snapshot and the authoritative post-write scan. + if (!rollback) { + if (item.kind === 'desktop-autostart' && journal.beforeContent) { + rollback = { kind: 'linux-desktop', content: journal.beforeContent, evidence: stateEvidence(item.internal && item.internal.evidence) } + } else if (item.kind === 'systemd-unit' || item.kind === 'scheduled-task') { + rollback = { kind: item.kind === 'systemd-unit' ? 'linux-systemd' : 'win-task', enabled: Boolean(journal.before && journal.before.enabled) } + } + } + if (!rollback) { + warnings.push('上次启动项变更已发生但缺少安全撤销凭据,请人工核对') + return + } + const operationId = journal.operationId + operations.clear() + operations.set(operationId, { item, itemId: journal.itemId || null, before: journal.before || {}, rollback, createdAt: journal.createdAt || clock() }) + trim(operations, MAX_OPERATIONS) + persistedJournal = { ...journal, status: 'ready', rollback: serializable(rollback) } + try { await journalStore.write(persistedJournal) } catch {} + warnings.push('已恢复上次启动项变更的撤销记录,可继续撤销') + return { operationId, createdAt: journal.createdAt || clock() } + } + async function scan() { if (scanFlight) return scanFlight scanFlight = withLock(EXCLUSIVE_LOCK, async () => { @@ -48,7 +173,16 @@ function createManager(options = {}) { }) snapshots.set(snapshotId, { createdAt, byId, publicItems: items }) trim(snapshots, MAX_SNAPSHOTS) - return { snapshotId, platform, generatedAt: new Date(createdAt).toISOString(), items, warnings: (result.warnings || []).slice(0, 100) } + const warnings = (result.warnings || []).slice(0, 100) + const recovered = await reconcileJournal([...byId.values()], warnings) + return { + snapshotId, + platform, + generatedAt: new Date(createdAt).toISOString(), + items, + warnings: warnings.slice(0, 100), + ...(recovered ? { recoveredOperationId: recovered.operationId, recoveredOperationCreatedAt: recovered.createdAt } : {}), + } }) try { return await scanFlight } finally { scanFlight = null } } @@ -89,15 +223,34 @@ function createManager(options = {}) { } async function setEnabled(request = {}) { + if (shuttingDown) throw errorWithCode('插件正在退出,暂不接受新的启动项写入', 'SHUTTING_DOWN') if (typeof request.enabled !== 'boolean') throw errorWithCode('enabled 必须为布尔值', 'INVALID_REQUEST') const { item } = resolveItem(request.snapshotId, request.itemId) if (!item.action.canToggle || item.scope !== 'user') throw errorWithCode(item.action.reason || '该项目仅支持查看', 'READ_ONLY') return withLock(EXCLUSIVE_LOCK, async () => { + if (shuttingDown) throw errorWithCode('插件正在退出,暂不接受新的启动项写入', 'SHUTTING_DOWN') if (item.enabled === request.enabled) return { changed: false, item: publicItem(item, request.itemId), operationId: null } const before = { enabled: item.enabled, running: item.running } - const rollback = await adapter.applyEnabled(item, request.enabled, deps) - await refreshItem(item, rollback.state || { enabled: request.enabled, running: request.enabled ? item.running : false }) + let beforeContent = null + if (item.kind === 'desktop-autostart' && item.internal && item.internal.file) { + try { beforeContent = (await readState(item.internal.file, deps.fs)).content.toString('base64') } catch { + throw errorWithCode('无法在写入前保存启动项原始内容', 'STATE_UNKNOWN') + } + } const operationId = crypto.randomUUID() + await saveJournal({ version: JOURNAL_VERSION, status: 'pending', operationId, itemId: request.itemId, itemKey: item.key, enabled: request.enabled, before, beforeContent, createdAt: clock() }) + let rollback + try { + rollback = await adapter.applyEnabled(item, request.enabled, deps) + } catch (error) { + // Adapters verify and roll back on their own failure. Keep an unknown + // pending record when they cannot prove that, so the next session can + // reconcile instead of silently losing the user's undo path. + if (error && error.rollbackRestored === true) await clearJournal() + throw error + } + await refreshItem(item, rollback.state || { enabled: request.enabled, running: request.enabled ? item.running : false }) + await saveJournal({ version: JOURNAL_VERSION, status: 'ready', operationId, itemId: request.itemId, itemKey: item.key, enabled: request.enabled, before, beforeContent, rollback: serializable(rollback), createdAt: clock() }) operations.clear() operations.set(operationId, { item, itemId: request.itemId, before, rollback, createdAt: clock() }) trim(operations, MAX_OPERATIONS) @@ -106,19 +259,29 @@ function createManager(options = {}) { } async function undo(request = {}) { + if (shuttingDown) throw errorWithCode('插件正在退出,暂不接受新的启动项写入', 'SHUTTING_DOWN') if (typeof request.operationId !== 'string') throw errorWithCode('operationId 无效', 'INVALID_REQUEST') const operation = operations.get(request.operationId) if (!operation) throw errorWithCode('撤销记录不存在或已使用', 'OPERATION_NOT_FOUND') if (clock() - operation.createdAt > SNAPSHOT_TTL_MS) { operations.delete(request.operationId); throw errorWithCode('撤销记录已过期', 'OPERATION_EXPIRED') } return withLock(EXCLUSIVE_LOCK, async () => { + if (shuttingDown) throw errorWithCode('插件正在退出,暂不接受新的启动项写入', 'SHUTTING_DOWN') const result = await adapter.undo(operation.item, operation.rollback, deps) await refreshItem(operation.item, result && result.state ? result.state : operation.before) operations.delete(request.operationId) + await clearJournal() return { restored: true, item: publicItem(operation.item, operation.itemId) } }) } - return { scan, setEnabled, undo, _state: { snapshots, operations, locks } } + async function shutdown() { + shuttingDown = true + const started = Date.now() + while (locks.size && Date.now() - started < 200) await new Promise((resolve) => setTimeout(resolve, 5)) + return { state: 'shutdown', drained: locks.size === 0 } + } + + return { scan, setEnabled, undo, shutdown, _state: { snapshots, operations, locks, get shuttingDown() { return shuttingDown } } } } -module.exports = { MAX_OPERATIONS, MAX_SNAPSHOTS, SNAPSHOT_TTL_MS, createManager } +module.exports = { JOURNAL_FILE, JOURNAL_VERSION, MAX_OPERATIONS, MAX_SNAPSHOTS, SNAPSHOT_TTL_MS, createManager } diff --git a/plugins/system-manager/modules/startup-manager/public/preload/services.js b/plugins/system-manager/modules/startup-manager/public/preload/services.js index ad5cc942..c3aafe48 100644 --- a/plugins/system-manager/modules/startup-manager/public/preload/services.js +++ b/plugins/system-manager/modules/startup-manager/public/preload/services.js @@ -2,19 +2,30 @@ const { createManager } = require('./core/manager.cjs') +function hostStorage() { + const db = typeof window !== 'undefined' && window.ztools && window.ztools.dbStorage + if (!db || typeof db.getItem !== 'function' || typeof db.setItem !== 'function') return null + return { + get: (key) => db.getItem(key), + set: (key, value) => db.setItem(key, value), + remove: (key) => typeof db.removeItem === 'function' ? db.removeItem(key) : db.setItem(key, null), + } +} + let manager try { - manager = createManager() + manager = createManager({ storage: hostStorage() || undefined }) } catch { manager = { async scan() { return { snapshotId: '', platform: process.platform, generatedAt: new Date().toISOString(), items: [], warnings: ['当前平台暂不支持'] } }, async setEnabled() { throw new Error('当前平台暂不支持管理启动项') }, async undo() { throw new Error('当前平台暂不支持撤销') }, + async shutdown() { return { state: 'shutdown', drained: true } }, } } function safeMessage(error) { - const allowed = new Set(['INVALID_REQUEST', 'SNAPSHOT_EXPIRED', 'ITEM_NOT_FOUND', 'ITEM_BUSY', 'READ_ONLY', 'ITEM_CHANGED', 'NO_ROLLBACK', 'OPERATION_NOT_FOUND', 'OPERATION_EXPIRED', 'VERIFY_FAILED', 'STATE_UNKNOWN', 'ROLLBACK_FAILED', 'UNSAFE_FILE', 'TOOL_UNAVAILABLE']) + const allowed = new Set(['INVALID_REQUEST', 'SNAPSHOT_EXPIRED', 'ITEM_NOT_FOUND', 'ITEM_BUSY', 'READ_ONLY', 'ITEM_CHANGED', 'NO_ROLLBACK', 'OPERATION_NOT_FOUND', 'OPERATION_EXPIRED', 'VERIFY_FAILED', 'STATE_UNKNOWN', 'ROLLBACK_FAILED', 'UNSAFE_FILE', 'TOOL_UNAVAILABLE', 'SHUTTING_DOWN']) if (error && allowed.has(error.code)) return { code: error.code, message: error.message } return { code: 'OPERATION_FAILED', message: '操作失败,请刷新后重试' } } @@ -28,4 +39,8 @@ window.startupManager = Object.freeze({ scan: () => call('scan'), setEnabled: (request) => call('setEnabled', request), undo: (request) => call('undo', request), + // Lifecycle is coordinated by the suite root because ZTools exposes one + // onPluginOut callback per renderer. Keep this internal method on the + // frozen bridge so the root can drain module writes before teardown. + shutdown: () => manager.shutdown(), }) diff --git a/plugins/system-manager/modules/startup-manager/screenshots/main.png b/plugins/system-manager/modules/startup-manager/screenshots/main.png index f97ecfe8..9151e3c2 100644 Binary files a/plugins/system-manager/modules/startup-manager/screenshots/main.png and b/plugins/system-manager/modules/startup-manager/screenshots/main.png differ diff --git a/plugins/system-manager/modules/startup-manager/src/styles.css b/plugins/system-manager/modules/startup-manager/src/styles.css index 5195e640..969755a1 100644 --- a/plugins/system-manager/modules/startup-manager/src/styles.css +++ b/plugins/system-manager/modules/startup-manager/src/styles.css @@ -13,7 +13,7 @@ --ink-faint: #5f6879; --line: #d5dbdc; --line-strong: #bac4c7; - --accent: #315f78; + --accent: var(--plugin-primary-color, #315f78); --accent-wash: #e1ebf0; --healthy: #39705b; --healthy-wash: #e5efe9; @@ -36,7 +36,7 @@ --ink-faint: #98a5a8; --line: #333e42; --line-strong: #4a585c; - --accent: #83abc0; + --accent: var(--plugin-primary-color, #83abc0); --accent-wash: #243842; --healthy: #82b49e; --healthy-wash: #20372e; @@ -668,7 +668,7 @@ footer { --ink-faint: #98a5a8; --line: #333e42; --line-strong: #4a585c; - --accent: #83abc0; + --accent: var(--plugin-primary-color, #83abc0); --accent-wash: #243842; --healthy: #82b49e; --healthy-wash: #20372e; diff --git a/plugins/system-manager/modules/startup-manager/tests/manager.test.cjs b/plugins/system-manager/modules/startup-manager/tests/manager.test.cjs index 020b6239..e446e9e5 100644 --- a/plugins/system-manager/modules/startup-manager/tests/manager.test.cjs +++ b/plugins/system-manager/modules/startup-manager/tests/manager.test.cjs @@ -241,3 +241,32 @@ test('verified mutation keeps fallback state and undo journal when rescan fails' assert.equal(changed.item.running, false) assert.equal(manager._state.operations.has(changed.operationId), true) }) + +test('rollback journal survives a new manager session and reconciles undo', async () => { + const storage = new Map() + const journalStore = { + async get(key) { return storage.get(key) || null }, + async set(key, value) { storage.set(key, value) }, + async remove(key) { storage.delete(key) }, + } + const adapter = createAdapter() + const first = createManager({ platform: 'linux', adapter, storage: journalStore, clock: () => 1_700_000_000_000 }) + const scan = await first.scan() + const changed = await first.setEnabled({ snapshotId: scan.snapshotId, itemId: scan.items[0].id, enabled: false }) + assert.equal(storage.has('startup-rollback-v1'), true) + + const second = createManager({ platform: 'linux', adapter, storage: journalStore, clock: () => 1_700_000_000_000 }) + const resumed = await second.scan() + assert.match(resumed.warnings.join(' '), /恢复.*撤销记录/) + assert.equal(second._state.operations.has(changed.operationId), true) + await second.undo({ operationId: changed.operationId }) + assert.equal(storage.has('startup-rollback-v1'), false) +}) + +test('shutdown rejects new startup writes while allowing the active call to drain', async () => { + const adapter = createAdapter() + const manager = createManager({ platform: 'linux', adapter }) + const scan = await manager.scan() + await manager.shutdown() + await assert.rejects(manager.setEnabled({ snapshotId: scan.snapshotId, itemId: scan.items[0].id, enabled: false }), (error) => error.code === 'SHUTTING_DOWN') +}) diff --git a/plugins/system-manager/modules/startup-manager/tests/manifest.test.cjs b/plugins/system-manager/modules/startup-manager/tests/manifest.test.cjs index 7c6c3cf4..95d86395 100644 --- a/plugins/system-manager/modules/startup-manager/tests/manifest.test.cjs +++ b/plugins/system-manager/modules/startup-manager/tests/manifest.test.cjs @@ -26,10 +26,11 @@ test('manifest entries stay within plugin and point to source files', () => { } }) -test('preload exposes only scan, setEnabled, and undo', () => { +test('preload exposes scan, setEnabled, undo, and lifecycle shutdown', () => { const source = fs.readFileSync(path.join(root, 'public/preload/services.js'), 'utf8') const bridge = source.match(/window\.startupManager\s*=\s*Object\.freeze\(\{([\s\S]*?)\}\)/) assert.ok(bridge) - assert.deepEqual([...bridge[1].matchAll(/^\s*(\w+):/gm)].map((match) => match[1]), ['scan', 'setEnabled', 'undo']) + assert.deepEqual([...bridge[1].matchAll(/^\s*(\w+):/gm)].map((match) => match[1]), ['scan', 'setEnabled', 'undo', 'shutdown']) assert.doesNotMatch(source, /window\.startupManager.*(?:exec|runFile|readFile|writeFile)/s) + assert.doesNotMatch(source, /\b(?:window\.)?ztools\.onPluginOut\s*\(/) }) diff --git a/plugins/system-manager/modules/system-cleaner/public/styles.css b/plugins/system-manager/modules/system-cleaner/public/styles.css index c45a9417..f353abf6 100644 --- a/plugins/system-manager/modules/system-cleaner/public/styles.css +++ b/plugins/system-manager/modules/system-cleaner/public/styles.css @@ -6,13 +6,13 @@ --text: #202a2f; --text-muted: #5f6879; --border: #bac4c7; - --accent: #315f78; - --accent-strong: #244b63; + --accent: var(--plugin-primary-color, #315f78); + --accent-strong: var(--plugin-primary-color, #244b63); --accent-contrast: #ffffff; --warning: #8f5f18; --danger: #9a4642; --danger-contrast: #ffffff; - --focus: #315f78; + --focus: var(--plugin-primary-color, #315f78); --shadow: none; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei UI", sans-serif; } @@ -25,13 +25,13 @@ --text: #e2e8e9; --text-muted: #abb7ba; --border: #4a585c; - --accent: #83abc0; - --accent-strong: #a7c6d5; + --accent: var(--plugin-primary-color, #83abc0); + --accent-strong: var(--plugin-primary-color, #a7c6d5); --accent-contrast: #151b1e; --warning: #d5a85f; --danger: #dc8d88; --danger-contrast: #151b1e; - --focus: #83abc0; + --focus: var(--plugin-primary-color, #83abc0); --shadow: none; } diff --git a/plugins/system-manager/modules/system-cleaner/screenshots/main.png b/plugins/system-manager/modules/system-cleaner/screenshots/main.png index 499fe34f..199fdd83 100644 Binary files a/plugins/system-manager/modules/system-cleaner/screenshots/main.png and b/plugins/system-manager/modules/system-cleaner/screenshots/main.png differ diff --git a/plugins/system-manager/modules/system-diagnostic-report/README.md b/plugins/system-manager/modules/system-diagnostic-report/README.md index dbedd5cd..477d2a1a 100644 --- a/plugins/system-manager/modules/system-diagnostic-report/README.md +++ b/plugins/system-manager/modules/system-diagnostic-report/README.md @@ -43,4 +43,4 @@ npm test --workspace system-diagnostic-report npm run build:dist --workspace system-diagnostic-report ``` -模块 `dist/plugin.json` 会被共享 finalizer 删除;唯一可加载和发布的产物是系统管家根目录生成的 `dist/` 与 `release/system-manager-0.2.0.zip`。 +模块 `dist/plugin.json` 会被共享 finalizer 删除;唯一可加载和发布的产物是系统管家根目录生成的 `dist/` 与 `release/system-manager-0.2.1.zip`。 diff --git a/plugins/system-manager/modules/system-diagnostic-report/screenshots/main.png b/plugins/system-manager/modules/system-diagnostic-report/screenshots/main.png index 9fd3eab5..b80a26c2 100644 Binary files a/plugins/system-manager/modules/system-diagnostic-report/screenshots/main.png and b/plugins/system-manager/modules/system-diagnostic-report/screenshots/main.png differ diff --git a/plugins/system-manager/modules/system-diagnostic-report/src/styles.css b/plugins/system-manager/modules/system-diagnostic-report/src/styles.css index 03256aef..12d789bc 100644 --- a/plugins/system-manager/modules/system-diagnostic-report/src/styles.css +++ b/plugins/system-manager/modules/system-diagnostic-report/src/styles.css @@ -12,7 +12,7 @@ --ink-faint: #5f6879; --line: #d5dbdc; --line-strong: #bac4c7; - --accent: #315f78; + --accent: var(--plugin-primary-color, #315f78); --accent-contrast: #f8fbfc; --accent-wash: #e1ebf0; --healthy: #39705b; @@ -36,7 +36,7 @@ --ink-faint: #98a5a8; --line: #333e42; --line-strong: #4a585c; - --accent: #83abc0; + --accent: var(--plugin-primary-color, #83abc0); --accent-contrast: #151b1e; --accent-wash: #243842; --healthy: #82b49e; diff --git a/plugins/system-manager/package-lock.json b/plugins/system-manager/package-lock.json index 25920929..247df0ce 100644 --- a/plugins/system-manager/package-lock.json +++ b/plugins/system-manager/package-lock.json @@ -1,12 +1,12 @@ { "name": "system-manager", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "system-manager", - "version": "0.2.0", + "version": "0.2.1", "workspaces": [ "modules/*" ] diff --git a/plugins/system-manager/package.json b/plugins/system-manager/package.json index 94dd2373..45d88cde 100644 --- a/plugins/system-manager/package.json +++ b/plugins/system-manager/package.json @@ -1,6 +1,6 @@ { "name": "system-manager", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "description": "系统管家:系统诊断、应用卸载、开机启动、垃圾清理和局域网设备发现。", diff --git a/plugins/system-manager/public/dashboard/styles.css b/plugins/system-manager/public/dashboard/styles.css index 00488e24..edb7d914 100644 --- a/plugins/system-manager/public/dashboard/styles.css +++ b/plugins/system-manager/public/dashboard/styles.css @@ -14,7 +14,7 @@ --ink-faint: #5f6879; --line: #d5dbdc; --line-strong: #bac4c7; - --accent: #315f78; + --accent: var(--plugin-primary-color, #315f78); --accent-wash: #e1ebf0; --healthy: #39705b; --healthy-wash: #e5efe9; @@ -550,7 +550,7 @@ h1 { margin: 0; font-size: clamp(26px, 4vw, 34px); line-height: 1.15; } --ink-faint: #8e9b9f; --line: #333e42; --line-strong: #4a585c; - --accent: #83abc0; + --accent: var(--plugin-primary-color, #83abc0); --accent-wash: #243842; --healthy: #82b49e; --healthy-wash: #20372e; diff --git a/plugins/system-manager/public/plugin.json b/plugins/system-manager/public/plugin.json index de5ae769..f516d9f3 100644 --- a/plugins/system-manager/public/plugin.json +++ b/plugins/system-manager/public/plugin.json @@ -3,7 +3,7 @@ "title": "系统管家", "description": "集中查看软硬件状态,并管理应用卸载、开机启动、垃圾清理与局域网设备。", "author": "Harris", - "version": "0.2.0", + "version": "0.2.1", "main": "index.html", "preload": "preload/index.cjs", "logo": "logo.svg", @@ -502,13 +502,14 @@ } }, "prepare_lan_scan": { - "description": "需要用户在系统管家界面短时授权;预览一次会产生主动 ICMP 网络流量的受限局域网扫描,最多覆盖本机 /24;可选 DNS 解析默认关闭,90 秒后 action 失效。", + "description": "需要用户在系统管家界面短时授权;预览一次会产生主动 ICMP 网络流量的受限局域网扫描,最多覆盖本机 /24。虚拟、VPN、共享或未知接口还必须在 prepare 请求中显式确认;可选 DNS 解析默认关闭,90 秒后 action 失效。", "inputSchema": { "type": "object", "additionalProperties": false, - "properties": { + "properties": { "interfaceId": { "type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9_-]{0,199}$" }, - "resolveHostnames": { "type": "boolean", "default": false } + "resolveHostnames": { "type": "boolean", "default": false }, + "confirmRestrictedInterface": { "type": "boolean", "default": false, "description": "受限或虚拟接口必须显式确认后才能扫描" } }, "required": ["interfaceId"] }, @@ -526,7 +527,7 @@ } }, "scan_lan_devices": { - "description": "需要用户在系统管家界面短时授权;消费 90 秒 action 并对选定接口发送有界 ICMP 探测,最多每 15 秒启动一次并产生主动局域网流量;仅在预览启用时执行 DNS 解析,不做端口或公网扫描。", + "description": "需要用户在系统管家界面短时授权;消费 90 秒 action 并对选定接口发送有界 ICMP 探测,最多每 15 秒启动一次并产生主动局域网流量。受限接口只能使用已在 prepare 阶段显式确认的 action;仅在预览启用时执行 DNS 解析,不做端口或公网扫描。", "inputSchema": { "type": "object", "additionalProperties": false, diff --git a/plugins/system-manager/public/preload/index.cjs b/plugins/system-manager/public/preload/index.cjs index 0d7bbff4..ab505abf 100644 --- a/plugins/system-manager/public/preload/index.cjs +++ b/plugins/system-manager/public/preload/index.cjs @@ -57,6 +57,15 @@ function bootstrap(hostWindow, options = {}) { ? loadFeatureService(installed.page.featureCode, runtimeRequire) : false if (serviceLoaded) runtime.attachCurrentFeatureBridge() + // ZTools keeps one onPluginOut callback per renderer. Register after the + // lazy module service so this coordinator remains the single shutdown + // owner and can drain both Agent journal entries and module recovery hooks. + const hostApi = hostWindow.ztools + let lifecycleInstalled = false + if (hostApi && typeof hostApi.onPluginOut === 'function') { + hostApi.onPluginOut(() => { void runtime.shutdown() }) + lifecycleInstalled = true + } return Object.freeze({ page: installed.page, router: installed.router, @@ -64,6 +73,7 @@ function bootstrap(hostWindow, options = {}) { mcpToolsRegistered: registeredToolNames.length, registeredToolNames, agentAccessInstalled: access.installed, + lifecycleInstalled, }) } diff --git a/plugins/system-manager/public/preload/mcp-tools.cjs b/plugins/system-manager/public/preload/mcp-tools.cjs index 3b68b49e..c6bcf187 100644 --- a/plugins/system-manager/public/preload/mcp-tools.cjs +++ b/plugins/system-manager/public/preload/mcp-tools.cjs @@ -42,6 +42,8 @@ const SAFE_CODES = new Set([ 'SAVE_CANCELLED', 'SAVE_UNAVAILABLE', 'SNAPSHOT_EXPIRED', + 'SHUTTING_DOWN', + 'STATE_UNKNOWN', ]) function boundedMessage(value, fallback) { diff --git a/plugins/system-manager/public/preload/suite-runtime.cjs b/plugins/system-manager/public/preload/suite-runtime.cjs index 68293ad5..49ba2731 100644 --- a/plugins/system-manager/public/preload/suite-runtime.cjs +++ b/plugins/system-manager/public/preload/suite-runtime.cjs @@ -276,6 +276,8 @@ function unwrapBridge(result) { ITEM_CHANGED: 'ITEM_CHANGED', OPERATION_NOT_FOUND: 'OPERATION_NOT_FOUND', OPERATION_EXPIRED: 'OPERATION_NOT_FOUND', + STATE_UNKNOWN: 'STATE_UNKNOWN', + SHUTTING_DOWN: 'SHUTTING_DOWN', }[code] if (mapped) throw runtimeError(mapped, cleanText(result.error.message, 200) || 'The request is no longer valid') throw new Error('Business bridge operation failed') @@ -381,6 +383,18 @@ function createSuiteRuntime(options = {}) { const journal = createOperationJournal(clock) let currentAppInventoryId = null let lastLanScanStartedAt = null + let lifecycle = 'active' + const inFlightWrites = new Set() + function hostStorage(namespace) { + const db = hostWindow && hostWindow.ztools && hostWindow.ztools.dbStorage + if (!db || typeof db.getItem !== 'function' || typeof db.setItem !== 'function') return undefined + const keyFor = (key) => namespace ? `${namespace}:${key}` : key + return { + get: (key) => db.getItem(keyFor(key)), + set: (key, value) => db.setItem(keyFor(key), value), + remove: (key) => typeof db.removeItem === 'function' ? db.removeItem(keyFor(key)) : db.setItem(keyFor(key), null), + } + } const moduleDomain = page.kind === 'module' ? FEATURE_DOMAINS[page.featureCode] : null @@ -399,6 +413,41 @@ function createSuiteRuntime(options = {}) { } } + function runtimeWriteError() { + return runtimeError('SHUTTING_DOWN', 'The plugin is shutting down; new write operations are rejected') + } + + function runWriteJournal(tool, key, signature, task) { + const existing = journal._records.get(key) + if (!existing && lifecycle !== 'active') throw runtimeWriteError() + const result = journal.run(tool, key, signature, task) + if (!existing) { + inFlightWrites.add(result) + const release = () => { inFlightWrites.delete(result) } + result.then(release, release) + } + return result + } + + async function shutdown() { + if (lifecycle === 'shutdown') return { state: lifecycle, drained: true } + lifecycle = 'shutting-down' + const pending = [...inFlightWrites] + const serviceShutdowns = [...serviceCache.values()] + .filter((value) => value && typeof value.shutdown === 'function') + .map((value) => Promise.resolve().then(() => value.shutdown())) + if (pending.length || serviceShutdowns.length) { + let timer + await Promise.race([ + Promise.allSettled([...pending, ...serviceShutdowns]), + new Promise((resolve) => { timer = setTimeout(resolve, 200); if (timer && typeof timer.unref === 'function') timer.unref() }), + ]) + if (timer) clearTimeout(timer) + } + lifecycle = 'shutdown' + return { state: lifecycle, drained: inFlightWrites.size === 0 } + } + function trimSnapshots(map, max) { const current = clock() for (const [key, value] of map) if (!value || value.expiresAt <= current) map.delete(key) @@ -522,12 +571,12 @@ function createSuiteRuntime(options = {}) { function directApplicationService() { const { shell } = runtimeRequire('electron') const { createEngine } = runtimeRequire('../modules/application-uninstaller/preload/core/engine.cjs') - return createEngine({ trashItem: (target) => shell.trashItem(target), revealItem: (target) => shell.showItemInFolder(target) }) + return createEngine({ storage: hostStorage(''), trashItem: (target) => shell.trashItem(target), revealItem: (target) => shell.showItemInFolder(target) }) } function directStartupService() { const { createManager } = runtimeRequire('../modules/startup-manager/preload/core/manager.cjs') - return createManager() + return createManager({ storage: hostStorage('') }) } function directCleanerService() { @@ -689,7 +738,7 @@ function createSuiteRuntime(options = {}) { const defaultName = validation.optionalString(input.defaultName, 'defaultName', { min: 1, max: 160, pattern: /^[^\\/\u0000-\u001f]+$/ }) const key = validation.idempotencyKey(input.idempotencyKey) const signature = JSON.stringify({ reportId, format, defaultName: defaultName || null }) - return journal.run('export_diagnostic_report', key, signature, async () => { + return runWriteJournal('export_diagnostic_report', key, signature, async () => { requireAuthorization('report_export') const snapshot = reportSnapshot(reportId) return { @@ -790,7 +839,7 @@ function createSuiteRuntime(options = {}) { const input = validation.plainObject(request, ['actionId', 'idempotencyKey']) const actionId = validation.id(input.actionId, 'actionId') const key = validation.idempotencyKey(input.idempotencyKey) - return journal.run('execute_application_removal', key, actionId, async () => { + return runWriteJournal('execute_application_removal', key, actionId, async () => { requireAuthorization('application_removal') const action = consumeAction(actionId, 'application_removal') const result = await service('applications').executePlan({ planId: action.planId, selectedIds: action.selectedIds, confirmation: action.appName }) @@ -813,6 +862,12 @@ function createSuiteRuntime(options = {}) { const includeCommandSummary = validation.optionalBoolean(input.includeCommandSummary, 'includeCommandSummary', false) const raw = unwrapBridge(await service('startup').scan()) const snapshotId = validation.id(raw.snapshotId, 'snapshotId') + if (raw.recoveredOperationId) { + // The module has already enforced its own 10-minute journal TTL. Use + // this runtime's clock for the rehydrated metadata to avoid mixing + // wall-clock sources in tests or after a system clock adjustment. + startupOperations.set(raw.recoveredOperationId, { includeCommandSummary, expiresAt: clock() + INVENTORY_TTL_MS }) + } const items = (Array.isArray(raw.items) ? raw.items : []).slice(0, 5000).map((item) => sanitizeStartupItem(item, includeCommandSummary)) const snapshot = { snapshotId, items, includeCommandSummary, expiresAt: clock() + INVENTORY_TTL_MS } startupSnapshots.set(snapshotId, snapshot) @@ -867,7 +922,7 @@ function createSuiteRuntime(options = {}) { const input = validation.plainObject(request, ['actionId', 'idempotencyKey']) const actionId = validation.id(input.actionId, 'actionId') const key = validation.idempotencyKey(input.idempotencyKey) - return journal.run('set_startup_item_enabled', key, actionId, async () => { + return runWriteJournal('set_startup_item_enabled', key, actionId, async () => { requireAuthorization('startup_changes') const action = consumeAction(actionId, 'startup_change') const result = unwrapBridge(await service('startup').setEnabled({ snapshotId: action.snapshotId, itemId: action.itemId, enabled: action.enabled })) @@ -885,7 +940,7 @@ function createSuiteRuntime(options = {}) { const input = validation.plainObject(request, ['operationId', 'idempotencyKey']) const operationId = validation.id(input.operationId, 'operationId') const key = validation.idempotencyKey(input.idempotencyKey) - return journal.run('undo_startup_change', key, operationId, async () => { + return runWriteJournal('undo_startup_change', key, operationId, async () => { requireAuthorization('startup_changes') const metadata = startupOperations.get(operationId) if (!metadata || metadata.expiresAt <= clock()) throw runtimeError('OPERATION_NOT_FOUND', 'startup operation is unknown or expired') @@ -955,7 +1010,7 @@ function createSuiteRuntime(options = {}) { const input = validation.plainObject(request, ['actionId', 'idempotencyKey']) const actionId = validation.id(input.actionId, 'actionId') const key = validation.idempotencyKey(input.idempotencyKey) - return journal.run('clean_system_junk', key, actionId, async () => { + return runWriteJournal('clean_system_junk', key, actionId, async () => { requireAuthorization('system_cleanup') const action = consumeAction(actionId, 'system_cleanup') const result = await service('cleaner').clean({ snapshotId: action.snapshotId, candidateIds: action.candidateIds, confirmation: '移到废纸篓' }) @@ -986,18 +1041,23 @@ function createSuiteRuntime(options = {}) { } async function prepare_lan_scan(request) { - const input = validation.plainObject(request, ['interfaceId', 'resolveHostnames']) + const input = validation.plainObject(request, ['interfaceId', 'resolveHostnames', 'confirmRestrictedInterface']) const interfaceId = validation.id(input.interfaceId, 'interfaceId') const resolveHostnames = validation.optionalBoolean(input.resolveHostnames, 'resolveHostnames', false) + const confirmRestrictedInterface = validation.optionalBoolean(input.confirmRestrictedInterface, 'confirmRestrictedInterface', false) requireAuthorization('lan_scan') const values = await service('network').listInterfaces() const selected = (Array.isArray(values) ? values : []).find((item) => item && item.id === interfaceId) if (!selected) throw runtimeError('INVALID_INTERFACE', 'interfaceId is not an active supported interface') + if (selected.requiresConfirmation && !confirmRestrictedInterface) { + throw runtimeError('CONFIRMATION_REQUIRED', 'This interface requires explicit confirmation before scanning') + } const safeInterface = sanitizeInterface(selected) - return createAction('lan_scan', { interfaceId, resolveHostnames, interface: safeInterface }, { + return createAction('lan_scan', { interfaceId, resolveHostnames, confirmRestrictedInterface, interface: safeInterface }, { interface: safeInterface, resolveHostnames, requiresConfirmation: Boolean(selected.requiresConfirmation), + confirmRestrictedInterface, }) } @@ -1005,7 +1065,7 @@ function createSuiteRuntime(options = {}) { const input = validation.plainObject(request, ['actionId', 'idempotencyKey']) const actionId = validation.id(input.actionId, 'actionId') const key = validation.idempotencyKey(input.idempotencyKey) - return journal.run('scan_lan_devices', key, actionId, async () => { + return runWriteJournal('scan_lan_devices', key, actionId, async () => { requireAuthorization('lan_scan') validateAction(actionId, 'lan_scan') if (lastLanScanStartedAt != null && clock() - lastLanScanStartedAt < LAN_SCAN_MIN_INTERVAL_MS) { @@ -1019,7 +1079,7 @@ function createSuiteRuntime(options = {}) { const result = await service('network').scan({ interfaceId: action.interfaceId, resolveHostnames: action.resolveHostnames, - confirmRestrictedInterface: true, + confirmRestrictedInterface: action.confirmRestrictedInterface === true, }) return { scanId: cleanText(result && result.scanId, 200), @@ -1079,8 +1139,10 @@ function createSuiteRuntime(options = {}) { prepare_lan_scan, scan_lan_devices, get_operation_result, + shutdown, + isShuttingDown: () => lifecycle !== 'active', attachCurrentFeatureBridge, - _state: Object.freeze({ actions, appInventories, appPlans, cleanerSnapshots, journal, reports, runtimeSessionId, serviceCache, startupOperations, startupSnapshots }), + _state: Object.freeze({ actions, appInventories, appPlans, cleanerSnapshots, journal, reports, runtimeSessionId, serviceCache, startupOperations, startupSnapshots, inFlightWrites, get lifecycle() { return lifecycle } }), }) } diff --git a/plugins/system-manager/screenshots/main.png b/plugins/system-manager/screenshots/main.png index 04f7d46e..a8e5a2af 100644 Binary files a/plugins/system-manager/screenshots/main.png and b/plugins/system-manager/screenshots/main.png differ diff --git a/plugins/system-manager/scripts/navigation-assets.mjs b/plugins/system-manager/scripts/navigation-assets.mjs index fe360c9f..2ad4f75d 100644 --- a/plugins/system-manager/scripts/navigation-assets.mjs +++ b/plugins/system-manager/scripts/navigation-assets.mjs @@ -68,7 +68,7 @@ export const navigationStyle = String.raw`:root { --system-manager-suitebar-heig display: inline-flex; min-height: 44px; align-items: center; - color: #315f78; + color: var(--plugin-primary-color, #315f78); text-decoration: none; } .system-manager-home-link::before { @@ -77,7 +77,7 @@ export const navigationStyle = String.raw`:root { --system-manager-suitebar-heig font-size: 15px; } .system-manager-home-link:hover { text-decoration: underline; text-underline-offset: 3px; } -.system-manager-home-link:focus-visible { outline: 3px solid #315f78; outline-offset: 2px; } +.system-manager-home-link:focus-visible { outline: 3px solid var(--plugin-primary-color, #315f78); outline-offset: 2px; } .system-manager-suitebar-separator { color: #8b969b; } .system-manager-suitebar-current { overflow: hidden; color: #5d696f; text-overflow: ellipsis; white-space: nowrap; } .system-manager-suitebar-local { @@ -112,19 +112,19 @@ body[data-system-manager-module="system-diagnostic-report"] .report-index { } @media (prefers-color-scheme: dark) { .system-manager-suitebar { color: #e2e8e9; border-color: #333e42; background: #1b2225; } - .system-manager-home-link { color: #83abc0; } - .system-manager-home-link:focus-visible { outline-color: #83abc0; } + .system-manager-home-link { color: var(--plugin-primary-color, #83abc0); } + .system-manager-home-link:focus-visible { outline-color: var(--plugin-primary-color, #83abc0); } .system-manager-suitebar-current { color: #abb7ba; } .system-manager-suitebar-local { color: #82b49e; } } :root[data-theme="dark"] .system-manager-suitebar { color: #e2e8e9; border-color: #333e42; background: #1b2225; } -:root[data-theme="dark"] .system-manager-home-link { color: #83abc0; } -:root[data-theme="dark"] .system-manager-home-link:focus-visible { outline-color: #83abc0; } +:root[data-theme="dark"] .system-manager-home-link { color: var(--plugin-primary-color, #83abc0); } +:root[data-theme="dark"] .system-manager-home-link:focus-visible { outline-color: var(--plugin-primary-color, #83abc0); } :root[data-theme="dark"] .system-manager-suitebar-current { color: #abb7ba; } :root[data-theme="dark"] .system-manager-suitebar-local { color: #82b49e; } :root[data-theme="light"] .system-manager-suitebar { color: #202a2f; border-color: #d5dbdc; background: #f8f9f7; } -:root[data-theme="light"] .system-manager-home-link { color: #315f78; } -:root[data-theme="light"] .system-manager-home-link:focus-visible { outline-color: #315f78; } +:root[data-theme="light"] .system-manager-home-link { color: var(--plugin-primary-color, #315f78); } +:root[data-theme="light"] .system-manager-home-link:focus-visible { outline-color: var(--plugin-primary-color, #315f78); } :root[data-theme="light"] .system-manager-suitebar-current { color: #5d696f; } :root[data-theme="light"] .system-manager-suitebar-local { color: #39705b; } ` diff --git a/plugins/system-manager/tests/router.test.mjs b/plugins/system-manager/tests/router.test.mjs index c933ee35..7cd176c3 100644 --- a/plugins/system-manager/tests/router.test.mjs +++ b/plugins/system-manager/tests/router.test.mjs @@ -161,8 +161,12 @@ test('bootstrap loads no service on dashboard and exactly one cjs service per mo for (const module of modules) { const loads = [] const moduleRegistered = [] + const lifecycleCallbacks = [] const { host: moduleHost } = hostAt(hrefFor(`modules/${module.id}/index.html`)) - moduleHost.ztools = { registerTool(name) { moduleRegistered.push(name) } } + moduleHost.ztools = { + registerTool(name) { moduleRegistered.push(name) }, + onPluginOut(callback) { lifecycleCallbacks.push(callback) }, + } const result = bootstrap(moduleHost, { suiteRoot, runtimeRequire: (value) => loads.push(value), @@ -174,6 +178,7 @@ test('bootstrap loads no service on dashboard and exactly one cjs service per mo assert.deepEqual(moduleRegistered, TOOL_NAMES) assert.equal(Object.hasOwn(moduleHost, 'systemManagerAgentAccess'), false) assert.deepEqual(loads, [`../modules/${module.id}/preload/services.cjs`]) + assert.equal(lifecycleCallbacks.length, 1, `${module.id} has one root lifecycle callback`) } }) diff --git a/plugins/system-manager/tests/suite-runtime.test.mjs b/plugins/system-manager/tests/suite-runtime.test.mjs index 794fb8f0..d6ff2db3 100644 --- a/plugins/system-manager/tests/suite-runtime.test.mjs +++ b/plugins/system-manager/tests/suite-runtime.test.mjs @@ -39,6 +39,8 @@ function createHarness(overrides = {}) { cleanerClean: 0, networkScan: 0, startupSet: 0, + applicationShutdown: 0, + startupShutdown: 0, } const app = { @@ -86,6 +88,10 @@ function createHarness(overrides = {}) { results: request.selectedIds.map((candidateId) => ({ candidateId, status: 'trashed' })), } }, + async shutdown() { + calls.applicationShutdown += 1 + return { state: 'shutdown', drained: true } + }, }) const startupBridge = Object.freeze({ @@ -148,6 +154,10 @@ function createHarness(overrides = {}) { }, } }, + async shutdown() { + calls.startupShutdown += 1 + return { state: 'shutdown', drained: true } + }, }) const cleanerBridge = Object.freeze({ @@ -192,8 +202,8 @@ function createHarness(overrides = {}) { prefixLength: 24, scope: 'private', kind: 'physical', - requiresConfirmation: false, - riskReason: null, + requiresConfirmation: Boolean(overrides.restrictedInterface), + riskReason: overrides.restrictedInterface ? '虚拟或桥接接口' : null, }) const networkBridge = Object.freeze({ async listInterfaces() { return [networkInterface] }, @@ -260,6 +270,58 @@ function createHarness(overrides = {}) { } } +test('runtime shutdown drains the loaded feature bridge and rejects new writes', async () => { + const harness = createHarness({ featureCode: 'application-uninstaller' }) + const result = await harness.runtime.shutdown() + assert.deepEqual(result, { state: 'shutdown', drained: true }) + assert.equal(harness.calls.applicationShutdown, 1) + await assert.rejects( + harness.runtime.execute_application_removal({ actionId: 'action_1', idempotencyKey: 'idempotency_1' }), + expectCode('SHUTTING_DOWN'), + ) + assert.equal(harness.runtime._state.lifecycle, 'shutdown') +}) + +test('LAN restricted interfaces require explicit confirmation in the prepared action', async () => { + const harness = createHarness({ featureCode: 'lan-device-discovery', restrictedInterface: true }) + harness.grant('lan_scan') + await assert.rejects( + harness.runtime.prepare_lan_scan({ interfaceId: 'interface_main' }), + expectCode('CONFIRMATION_REQUIRED'), + ) + const prepared = await harness.runtime.prepare_lan_scan({ interfaceId: 'interface_main', confirmRestrictedInterface: true }) + assert.equal(prepared.summary.requiresConfirmation, true) + assert.equal(prepared.summary.confirmRestrictedInterface, true) +}) + +test('reconciled startup rollback is registered for root MCP undo in a new runtime session', async () => { + let undoCalls = 0 + const startupBridge = Object.freeze({ + async scan() { + return { + snapshotId: 'startup_recovered_001', platform: 'linux', generatedAt: new Date().toISOString(), + recoveredOperationId: 'startup_recovered_operation_001', recoveredOperationCreatedAt: Date.now(), warnings: [], + items: [{ id: 'startup_item_001', name: 'Recovered service', scope: 'user', kind: 'systemd-unit', source: { label: 'systemd' }, trigger: 'login', enabled: false, running: false, status: 'disabled', impact: { reasons: [] }, action: { canToggle: true, requiresElevation: false } }], + } + }, + async setEnabled() { throw new Error('not used') }, + async undo(request) { + undoCalls += 1 + return { restored: true, item: { id: request.operationId, name: 'Recovered service', scope: 'user', kind: 'systemd-unit', source: { label: 'systemd' }, enabled: true, running: false, status: 'idle', impact: { reasons: [] }, action: { canToggle: true, requiresElevation: false } } } + }, + }) + const hostWindow = { ztools: {} } + Object.defineProperty(hostWindow, 'startupManager', { configurable: false, enumerable: true, value: startupBridge }) + const runtime = createSuiteRuntime({ + hostWindow, + page: Object.freeze({ kind: 'module', featureCode: 'startup-manager' }), + agentAccess: Object.freeze({ hasScope(scope) { return scope === 'startup_changes' }, getState() { return {} } }), + }) + await runtime.scan_startup_items({ pageSize: 1 }) + await runtime.undo_startup_change({ operationId: 'startup_recovered_operation_001', idempotencyKey: 'startup_recovered_undo_001' }) + assert.equal(undoCalls, 1) +}) + async function applicationPlan(runtime) { const inventory = await runtime.scan_applications({ pageSize: 1 }) const inspection = await runtime.inspect_application({ inventoryId: inventory.inventoryId, appId: inventory.items[0].id }) diff --git a/plugins/system-manager/tests/theme-color.test.mjs b/plugins/system-manager/tests/theme-color.test.mjs new file mode 100644 index 00000000..c0cb8aff --- /dev/null +++ b/plugins/system-manager/tests/theme-color.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import test from 'node:test' + +import { navigationStyle } from '../scripts/navigation-assets.mjs' +import { root } from '../scripts/config.mjs' + +const styleFiles = Object.freeze([ + 'public/dashboard/styles.css', + 'modules/startup-manager/src/styles.css', + 'modules/lan-device-discovery/src/styles.css', + 'modules/application-uninstaller/src/styles.css', + 'modules/system-cleaner/public/styles.css', + 'modules/system-diagnostic-report/src/styles.css', +]) + +const semanticNames = Object.freeze(['--success', '--healthy', '--warning', '--danger', '--error']) + +test('frontend styles consume injected primary color without replacing semantic status colors', async () => { + const sources = await Promise.all(styleFiles.map(async (relative) => [ + relative, + await readFile(path.join(root, relative), 'utf8'), + ])) + + for (const [relative, source] of sources) { + assert.match(source, /--accent:\s*var\(--plugin-primary-color,\s*#315f78\)/, relative) + assert.match(source, /--(?:accent|focus):\s*var\(--plugin-primary-color,\s*#83abc0\)/, relative) + for (const semantic of semanticNames) { + const declarations = source.match(new RegExp(`${semantic}\\s*:[^;]+`, 'g')) || [] + assert.ok(declarations.every((declaration) => !declaration.includes('--plugin-primary-color')), `${relative}: ${semantic} must remain semantic`) + } + } +}) + +test('SuiteBar link and focus colors consume the injected primary color with theme fallbacks', () => { + assert.match(navigationStyle, /\.system-manager-home-link\s*\{[^}]*color:\s*var\(--plugin-primary-color,\s*#315f78\)/s) + assert.match(navigationStyle, /\.system-manager-home-link:focus-visible\s*\{[^}]*outline(?:-color)?:\s*(?:3px solid )?var\(--plugin-primary-color,\s*#315f78\)/s) + assert.match(navigationStyle, /\.system-manager-home-link\s*\{\s*color:\s*var\(--plugin-primary-color,\s*#83abc0\)/s) + assert.match(navigationStyle, /\.system-manager-home-link:focus-visible\s*\{\s*outline-color:\s*var\(--plugin-primary-color,\s*#83abc0\)/s) +}) diff --git a/plugins/system-manager/tests/workspace.test.mjs b/plugins/system-manager/tests/workspace.test.mjs index 97891fb8..83d506d6 100644 --- a/plugins/system-manager/tests/workspace.test.mjs +++ b/plugins/system-manager/tests/workspace.test.mjs @@ -19,7 +19,7 @@ async function testFiles(directory) { test('root package declares one workspace for every fixed system manager module', async () => { const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')) assert.equal(packageJson.name, 'system-manager') - assert.equal(packageJson.version, '0.2.0') + assert.equal(packageJson.version, '0.2.1') assert.deepEqual(packageJson.workspaces, ['modules/*']) assert.equal(packageJson.scripts.build, 'node scripts/build.mjs') assert.match(packageJson.scripts.test, /npm run build/) diff --git a/plugins/system-manager/tests/zip.test.mjs b/plugins/system-manager/tests/zip.test.mjs index ca7e1e1e..efeafada 100644 --- a/plugins/system-manager/tests/zip.test.mjs +++ b/plugins/system-manager/tests/zip.test.mjs @@ -39,7 +39,7 @@ test('createZip rejects duplicate entries and symlink inputs', async (context) = }) test('inspectZip compares central method, CRC, sizes and local offset with local records', async () => { - const original = await readFile(path.join(releaseRoot, 'system-manager-0.2.0.zip')) + const original = await readFile(path.join(releaseRoot, 'system-manager-0.2.1.zip')) const endOffset = original.length - 22 assert.equal(original.readUInt32LE(endOffset), 0x06054b50) const centralOffset = original.readUInt32LE(endOffset + 16)