From fd46835dc977d8fac709453efdda5b23299aaf5a Mon Sep 17 00:00:00 2001 From: zhanglei <383094403@qq.com> Date: Tue, 6 Oct 2026 16:52:35 +0800 Subject: [PATCH] =?UTF-8?q?Add=20plugin=20Scoop=20=E5=8C=85=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=99=A8=20v1.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init - readme - changelog --- plugins/ztools-scoop-manager/CHANGELOG.md | 17 + plugins/ztools-scoop-manager/README.md | 131 +++ plugins/ztools-scoop-manager/app.js | 502 +++++++++ plugins/ztools-scoop-manager/index.html | 161 +++ plugins/ztools-scoop-manager/logo.png | Bin 0 -> 3066 bytes plugins/ztools-scoop-manager/plugin.json | 30 + plugins/ztools-scoop-manager/preload.js | 993 ++++++++++++++++++ .../ztools-scoop-manager/scripts/make_logo.py | 40 + plugins/ztools-scoop-manager/style.css | 191 ++++ .../ztools-scoop-manager/tests/preview.html | 100 ++ .../ztools-scoop-manager/tests/run_tests.js | 329 ++++++ 11 files changed, 2494 insertions(+) create mode 100644 plugins/ztools-scoop-manager/CHANGELOG.md create mode 100644 plugins/ztools-scoop-manager/README.md create mode 100644 plugins/ztools-scoop-manager/app.js create mode 100644 plugins/ztools-scoop-manager/index.html create mode 100644 plugins/ztools-scoop-manager/logo.png create mode 100644 plugins/ztools-scoop-manager/plugin.json create mode 100644 plugins/ztools-scoop-manager/preload.js create mode 100644 plugins/ztools-scoop-manager/scripts/make_logo.py create mode 100644 plugins/ztools-scoop-manager/style.css create mode 100644 plugins/ztools-scoop-manager/tests/preview.html create mode 100644 plugins/ztools-scoop-manager/tests/run_tests.js diff --git a/plugins/ztools-scoop-manager/CHANGELOG.md b/plugins/ztools-scoop-manager/CHANGELOG.md new file mode 100644 index 000000000..b542534a3 --- /dev/null +++ b/plugins/ztools-scoop-manager/CHANGELOG.md @@ -0,0 +1,17 @@ +## 1.0.0 - 2026-10-06 +- **五个页签**:已安装(筛选 / 更新 / 主页 / 目录 / 锁定 / 卸载)、 + 可更新(单个 / 全部更新,异常包分组提示)、搜索安装(实时日志)、 + Bucket(快捷添加 / 手动添加 / 移除)、缓存(统计 / 清空 / 清理旧版本)。 +- 已安装列表自动标注 `全局` / `已锁定` / `Install failed` 等状态标签; + 卸载两档确认(Alt+确认 = `scoop uninstall -p` 彻底卸载)。 +- 所有写操作互斥执行,底部日志抽屉实时滚动、随时终止;深浅色跟随宿主。 +- **环境自举**:未安装 scoop 时进入配置页,一键安装走官方脚本 + 社区反代镜像, + 强制 TLS 1.2、管理员自动 `-RunAsAdmin`、直连失败自动探测本机代理; + 也支持手动指定已有 shims 位置。 +- **Bucket 国内直连源**:快捷添加预置 Gitee 镜像(main)与 GitHub 反代 + (extras / versions / apps),均经 `git ls-remote` 实测。 +- **git 自动保障**:bucket 管理硬依赖 git,缺失时自动从 npmmirror + 的 git-for-windows 国内 CDN 静默安装。 +- **软件下载镜像化**:安装/更新前把清单里的 GitHub 下载地址就地临时改写为反代地址 + (原始清单自动备份、装完字节级还原),镜像代理原字节流,hash 校验不受影响; + 更新失败自动回退镜像清单强制重装。 \ No newline at end of file diff --git a/plugins/ztools-scoop-manager/README.md b/plugins/ztools-scoop-manager/README.md new file mode 100644 index 000000000..f2757f25f --- /dev/null +++ b/plugins/ztools-scoop-manager/README.md @@ -0,0 +1,131 @@ +# Scoop 包管理器(ztools-scoop-manager) + +[ZTools](https://github.com/ZToolsCenter/ZTools) 的 Scoop 图形化管理插件: +已装软件一览、一键更新/卸载、搜索安装、Bucket 管理与缓存清理。 +全部走本机 `scoop` 命令,无需注册、无需 API Key,**专为国内网络环境优化——全程无需梯子**。 + +> 仅支持 Windows。 + +## 功能 + +### 五个页签 + +| 页签 | 能做什么 | +|------|---------| +| **已安装** | 应用列表(名称 / 版本 / 来源 bucket / 更新时间 / 状态标签)、本地筛选;单个应用支持更新、打开主页、打开安装目录、锁定/解锁(锁定后 `scoop update` 跳过)、卸载 | +| **可更新** | 一键检查所有可更新软件,逐个更新或全部更新;「异常包」(清单已移除 / 安装失败)单独分组提示处理 | +| **搜索安装** | 搜索本地已添加的 bucket,未安装的一键安装,实时滚动日志 | +| **Bucket** | 查看已添加 bucket(来源 / 清单数 / 更新时间)、快捷添加常用 bucket、手动添加(支持名称 / git 地址 / 「名称 地址」)、移除 | +| **缓存** | 按应用聚合统计下载缓存占用、清空缓存(`cache rm *`)、清理旧版本(`cleanup *`) | + +### 状态标签 + +已安装列表自动标注:`全局`(Global install)、`已锁定`(Held package)、 +`Install failed` / `Manifest removed`(红色异常,建议在「可更新」页处理)。 + +### 卸载的两档 + +卸载默认保留持久化数据。按钮点一次进入确认态(状态栏有操作提示): + +- 再点一次「确认?」→ 普通卸载(保留数据) +- **Alt + 点击「确认?」** → 彻底卸载(`scoop uninstall -p`,连数据一起删) + +## 无梯子友好:四层国内镜像体系 + +插件面向所有用户设计,没有代理的机器可以走完全程: + +1. **没有 scoop?启动自举** + 启动时自动检测,检测失败进入配置页。一键安装走官方安装脚本 + + scoop.201704.xyz GitHub 反代(安装器内部所有 GitHub 下载地址自动打镜像前缀), + 强制 TLS 1.2(Windows PowerShell 5.1 默认不开,直连 GitHub 必挂的经典原因), + 管理员权限自动加 `-RunAsAdmin`,直连不通还会自动探测本机代理端口重试。 + 也可以手动指定已有的 scoop 位置。 +2. **加 bucket 不用梯子** + 快捷添加的 bucket 全部预置国内可直连源(`main` 走 Gitee 镜像, + `extras` / `versions` / `apps` 走 GitHub 反代),均已实测可用。 +3. **没有 git?自动补** + Scoop 的 bucket 管理硬依赖 git,而新装的 scoop 不带 git。 + 添加 bucket 前自动检测,缺就从 npmmirror 的 git-for-windows 国内 CDN + 下载最新稳定版静默安装。 +4. **装软件不走 GitHub** + 安装/更新前把 bucket 清单里的 GitHub 下载地址(releases / raw / objects / + codeload / api)**就地临时改写**为反代地址(原始清单自动备份、装完字节级还原), + 再正常执行安装。镜像代理的是原始文件,hash 校验不受影响; + 非 GitHub 地址(官网、FossHub 等)一律不动。 + +## 安装使用 + +1. 在 ZTools 插件市场搜索「Scoop 包管理器」安装; + 或用「ZTools 开发者工具」加载本仓库目录(支持热重载)。 +2. `Alt+Z` 唤起 ZTools,输入 `scoop` / `包管理` / `软件管理` / `更新软件` / `卸载软件` 打开。 +3. 没装过 scoop 也没关系,首次打开会引导你配置环境。 + +## 常见问题 + +**Q: 安装时提示执行策略错误?** +插件起的 PowerShell 自带 `-ExecutionPolicy Bypass`,该告警已被内部消化,不影响使用。 + +**Q: 安装报 "Hash Check Failed"?** +个别清单可能同步到了错误的文件,可临时用 `scoop install -s` 跳过校验。 + +**Q: 日志里出现 "Scoop uses 'aria2c' for multi-connection downloads" 警告?** +只是提示。若 aria2 多线程下载出问题,可运行 `scoop config aria2-enabled false` 关闭。 + +**Q: 之前手动装 scoop 失败过,留下半截目录?** +一键安装会自动把残骸改名(`scoop.bak-时间戳`)让路,不会删除你的文件。 + +**Q: 卸载了 scoop,插件还能恢复吗?** +能。重新打开插件会进入配置页,点「一键安装」即可重装。 + +## 实现要点(维护者向) + +- **解析不碰表格**:scoop 是 PowerShell 脚本,表格输出列宽随宿主控制台宽度漂移。 + 查询命令一律套 `| ConvertTo-Json`(v0.6.0 全部支持),`extractJson` 负责剥掉 + 前缀告警(git fatal / WARN 行)。已知坑都注释在代码现场: + - `scoop --version` 的版本行走 Write-Host 宿主流,管道里反而是 bucket 摘要, + 版本号必须锚定 `v` 前缀、从「数据 + 告警」合并文本里捞; + - PowerShell 5.1 对空数组管道 `ConvertTo-Json` 输出**空串**(status 全最新 / + search 无结果 / cache 已清空 / 新装无 git 四种场景都会踩),需 `allowEmpty`; + - `allowEmpty` 后还必须区分「跑了但空」与「命令不存在」(后者报错在 stderr), + 见 `classifyDetect`; + - `scoop prefix` 输出裸路径,走纯文本通道。 +- **防注入**:应用名 / bucket 名会拼进 `powershell -Command`, + 白名单 `[\w.@-]{1,100}`(覆盖 `7zip@16.04` 这类名),bucket 来源仅放行 + 官方短名 / https(s) URL / 盘符路径。 +- **清单就地改写**:安装/更新时临时改写 bucket 内清单并备份(`.ztools-bak`), + finally 里字节级还原。不能把改好的清单放临时目录——清单的 post_install + 会引用 `$bucketsdir\$bucket\...` 相对资源,脱离 bucket 上下文必挂(7zip 实测)。 +- **`window.exports` 的 mode 不要写 `'none'`**:宿主会按无界面插件处理导致打不开, + 固定 `'web'`。 + +## 目录结构 + +``` +├── plugin.json 插件清单 +├── preload.js scoop 命令封装 / JSON 提取 / 输入消毒 / 镜像化安装 +├── index.html 五页签界面骨架 + 配置页 +├── style.css 主题变量(跟随宿主深浅色) +├── app.js 页面逻辑(页签 / 表格 / 日志抽屉 / 两步确认 / 启动分流) +├── scripts/make_logo.py 生成 logo.png +└── tests/ + ├── run_tests.js 纯函数 + 实机冒烟(node tests/run_tests.js [--live]) + └── preview.html 浏览器假数据预览(?setup=1 模拟无 scoop 环境) +``` + +## 开发与测试 + +``` +node tests/run_tests.js # 纯函数测试(vm 沙箱加载 preload,不需要 scoop) +node tests/run_tests.js --live # 追加真实 scoop 冒烟(检测/列表/状态/缓存/搜索/清单改写往返等) +``` + +浏览器看 UI:`python -m http.server 8123` → +`http://127.0.0.1:8123/tests/preview.html`(`?setup=1` 看配置页)。 + +## 已知限制 + +- 仅支持 Windows(scoop 本身的限制)。 +- 搜索只覆盖本地已添加的 bucket;`scoop search` 的联网远端结果未接入。 +- 批量更新(`update *`)不做镜像兜底,个别 GitHub 下载失败的会在日志里显示, + 可到「已安装」页逐个点更新走镜像清单。 +- 一键安装的镜像源为社区服务(scoop.201704.xyz),若不可用会自动回退官方源与代理。 diff --git a/plugins/ztools-scoop-manager/app.js b/plugins/ztools-scoop-manager/app.js new file mode 100644 index 000000000..1dc9c6ef1 --- /dev/null +++ b/plugins/ztools-scoop-manager/app.js @@ -0,0 +1,502 @@ +/** + * Scoop 包管理器 · 页面逻辑(无 Node 环境,只调 window.services = preload.js 暴露的接口) + * 结构:顶部动作条 + 五个页签 + 底部任务日志抽屉。 + * 约定:所有写操作(安装/更新/卸载/bucket/清理)走 runOp():互斥、进抽屉、行内日志、结束自动刷新。 + */ +(function () { + 'use strict'; + const S = window.services; + const $ = (sel) => document.querySelector(sel); + + // ---------- 小工具 ---------- + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function fmtTime(ts) { + if (!ts) return '--'; + const d = new Date(Number(ts)); + if (isNaN(d.getTime())) return '--'; + const p = (n) => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()); + } + /** 表格里只显示日期(时分秒挤操作列的宽度),完整时间放 title 悬停 */ + function fmtDate(ts) { + if (!ts) return '--'; + const d = new Date(Number(ts)); + if (isNaN(d.getTime())) return '--'; + const p = (n) => String(n).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); + } + function fmtBytes(n) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let v = Number(n) || 0, i = 0; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return (i === 0 ? v : v.toFixed(1)) + ' ' + units[i]; + } + function setStatus(text, busy) { + $('#status-text').textContent = text; + $('#statusbar').classList.toggle('busy', !!busy); + } + /** 两步确认:第一次点变成"确认?",3 秒内再点才真的执行 */ + function armConfirm(btn) { + if (btn.dataset.armed === '1') { delete btn.dataset.armed; btn.textContent = btn.dataset.label; return true; } + btn.dataset.label = btn.textContent; + btn.dataset.armed = '1'; + btn.textContent = '确认?'; + // 6 秒:要留给用户读状态栏提示 + 按 Alt 的时间,3 秒经常不够 + setTimeout(() => { + if (btn.dataset.armed === '1') { delete btn.dataset.armed; btn.textContent = btn.dataset.label; } + }, 6000); + return false; + } + + // ---------- 主题 ---------- + function applyTheme() { + try { document.body.classList.toggle('dark', !!(S.theme() && S.theme().isDark)); } catch (e) {} + } + applyTheme(); + try { S.onThemeChange(applyTheme); } catch (e) {} + + // ---------- 任务运行(互斥 + 日志抽屉) ---------- + const drawer = $('#drawer'), drawerLog = $('#drawer-log'), drawerTitle = $('#drawer-title'); + $('#btn-drawer-close').onclick = () => { drawer.hidden = true; }; + $('#btn-drawer-toggle').onclick = () => { drawer.hidden = false; }; + $('#btn-op-stop').onclick = () => { + try { S.stopOp(); } catch (e) {} + appendLog('⏹ 已请求终止', 'err'); + setStatus('任务已终止'); + setBusy(false); + }; + + let busy = false; + function setBusy(b) { + busy = b; + document.querySelectorAll('#main .btn, #topbar .btn, #setup .btn').forEach((x) => { x.disabled = b; }); + $('#btn-drawer-toggle').disabled = false; + $('#btn-drawer-close').disabled = false; + $('#btn-op-stop').disabled = !b; + $('#statusbar').classList.toggle('busy', b); + } + function appendLog(line, cls) { + const div = document.createElement('div'); + if (cls) div.className = cls; + div.textContent = line; + drawerLog.appendChild(div); + drawerLog.scrollTop = drawerLog.scrollHeight; + } + + /** + * 运行一个写操作:抢互斥锁 → 开抽屉 → 流式收日志 → 结束刷新当前页签。 + * @param label 任务名(抽屉标题/状态栏) + * @param fn(preload 里带 onLine 参数的服务函数) 调用时传入行回调 + * @param refresh 完成后的刷新函数(可空) + */ + async function runOp(label, fn, refresh) { + if (busy) { S.notify('已有任务在执行,请先等它结束或终止'); return; } + drawer.hidden = false; + drawerLog.innerHTML = ''; + drawerTitle.textContent = label; + appendLog('$ scoop ' + label); + setBusy(true); + setStatus(label + ' …', true); + const t0 = Date.now(); + try { + await fn((line) => appendLog(line)); + const secs = Math.round((Date.now() - t0) / 1000); + appendLog('✅ 完成,用时 ' + secs + 's'); + setStatus(label + ' 完成(' + secs + 's)'); + try { S.notify(label + ' 完成'); } catch (e) {} + if (refresh) refresh(); + } catch (e) { + appendLog('❌ ' + (e && e.message ? e.message : e), 'err'); + setStatus(label + ' 失败'); + } finally { + setBusy(false); + } + } + + // ---------- 页签 ---------- + const tabs = document.querySelectorAll('.tab'); + tabs.forEach((t) => { + t.onclick = () => { + tabs.forEach((x) => x.classList.remove('active')); + t.classList.add('active'); + document.querySelectorAll('.pane').forEach((p) => p.classList.remove('active')); + $('#pane-' + t.dataset.tab).classList.add('active'); + if (t.dataset.tab === 'installed') loadInstalled(); + if (t.dataset.tab === 'bucket') loadBuckets(); + if (t.dataset.tab === 'cache') loadCache(); + }; + }); + + // ---------- 已安装 ---------- + let installedApps = []; + async function loadInstalled() { + const tbody = $('#tbl-installed tbody'); + tbody.innerHTML = '加载中…'; + setStatus('读取已安装列表 …', true); + try { + const r = await S.installed(); + installedApps = r.apps; + $('#cnt-installed').textContent = installedApps.length; + $('#installed-meta').textContent = '共 ' + installedApps.length + ' 个应用'; + renderInstalled(); + showWarnings('#installed-warnings', r.warnings); + setStatus('就绪'); + } catch (e) { + tbody.innerHTML = ''; + $('#tbl-installed').closest('.table-wrap').querySelector('.empty').hidden = false; + $('#tbl-installed').closest('.table-wrap').querySelector('.empty').textContent = + '读取失败:' + (e && e.message ? e.message : e); + showWarnings('#installed-warnings', [String(e && e.message || e)]); + setStatus('读取失败'); + } finally { setBusy(false); } + } + /** Info 列 → 彩色标签:锁定=黄,安装失败/清单移除=红,全局安装=灰,其余原样灰 */ + function infoTag(info) { + if (!info) return ''; + if (/held/i.test(info)) return '已锁定'; + if (/install failed|manifest removed/i.test(info)) return '' + esc(info) + ''; + if (/global install/i.test(info)) return '全局'; + return '' + esc(info) + ''; + } + function renderInstalled() { + const kw = ($('#filter-installed').value || '').trim().toLowerCase(); + const list = installedApps.filter((a) => !kw || a.name.toLowerCase().indexOf(kw) >= 0); + const tbody = $('#tbl-installed tbody'); + // 列结构:名称(副行带 bucket 与状态标签) / 版本 / 更新时间(仅日期) / 操作。 + // 之前 来源、状态 各占一列,窄面板下把最右侧的操作按钮挤出可视区—— + // 并入副行后整表至少省 ~180px,操作列在 ~640px 宽的宿主面板里也能完整显示。 + tbody.innerHTML = list.map((a) => { + const held = /held/i.test(a.info); + const tag = infoTag(a.info); + const sub = [ + a.source ? esc(a.source) : '来源未知', + tag + ].filter(Boolean).join(' '); + return '' + + '
' + esc(a.name) + '
' + sub + '
' + + '' + esc(a.version || '--') + '' + + '' + fmtDate(a.updated) + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }).join(''); + const empty = $('#pane-installed .empty'); + empty.hidden = list.length > 0; + empty.textContent = '没有匹配的应用'; + } + $('#filter-installed').oninput = renderInstalled; + + // ---------- 可更新 ---------- + async function loadStatus() { + const tbody = $('#tbl-outdated tbody'); + tbody.innerHTML = '检查中…(要联网对比各 bucket,可能要十几秒)'; + setStatus('检查更新 …', true); + try { + const r = await S.status(); + $('#cnt-outdated').textContent = r.outdated.length; + $('#status-meta').textContent = '可更新 ' + r.outdated.length + ' 个 · 异常 ' + r.broken.length + ' 个'; + tbody.innerHTML = r.outdated.map((a) => { + const held = /held/i.test(a.info); + return '' + + '' + esc(a.name) + '' + + '' + esc(a.installed) + '' + + '' + esc(a.latest) + '' + + '' + (held ? '已锁定' : '') + '' + + '' + + (held ? '' : '') + + ''; + }).join(''); + $('#pane-status .empty').hidden = r.outdated.length > 0; + + const brokenBox = $('#broken-box'); + brokenBox.hidden = r.broken.length === 0; + $('#tbl-broken tbody').innerHTML = r.broken.map((a) => + '' + esc(a.name) + '' + esc(a.installed || '--') + '' + + '' + esc(a.info || '异常') + '' + + '' + ).join(''); + showWarnings('#status-warnings', r.warnings); + setStatus('就绪'); + } catch (e) { + tbody.innerHTML = ''; + $('#pane-status .empty').hidden = false; + $('#pane-status .empty').textContent = '检查失败:' + (e && e.message ? e.message : e); + setStatus('检查失败'); + } finally { setBusy(false); } + } + + // ---------- 搜索安装 ---------- + async function doSearch() { + const kw = $('#search-input').value.trim(); + if (!kw) return; + const tbody = $('#tbl-search tbody'); + tbody.innerHTML = '搜索中…'; + $('#pane-search .empty').hidden = true; + setStatus('搜索 ' + kw + ' …', true); + try { + const r = await S.searchApps(kw); + const have = {}; + installedApps.forEach((a) => { have[a.name.toLowerCase()] = true; }); + tbody.innerHTML = r.results.map((x) => { + const owned = have[x.name.toLowerCase()]; + return '' + + '' + esc(x.name) + '' + + '' + esc(x.version) + '' + + '' + esc(x.source) + '' + + '' + + (owned ? '已安装' + : '') + + ''; + }).join(''); + $('#pane-search .empty').hidden = r.results.length > 0; + $('#pane-search .empty').textContent = '没搜到,试试别的关键词(只搜本地已添加的 bucket)'; + showWarnings('#search-warnings', r.warnings); + setStatus('搜索完成,' + r.results.length + ' 条结果'); + } catch (e) { + tbody.innerHTML = ''; + $('#pane-search .empty').hidden = false; + $('#pane-search .empty').textContent = '搜索失败:' + (e && e.message ? e.message : e); + } finally { setBusy(false); } + } + $('#btn-search').onclick = doSearch; + $('#search-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); }); + + // ---------- Bucket ---------- + // 快捷添加的 bucket 全部用国内可直连源(Gitee / GitHub 反代),面向没有代理的 + // 机器——scoop bucket add <名字> 不带来源时默认走官方 GitHub,没梯子必挂。 + // 源已用 git ls-remote 逐一实测可用(2026-10)。 + const M = S.MIRROR_PREFIX || 'https://scoop.201704.xyz/'; + const KNOWN_BUCKETS = [ + { name: 'main', source: 'https://gitee.com/scoop-bucket/main.git', desc: '官方主仓库 · Gitee 镜像' }, + { name: 'extras', source: M + 'https://github.com/ScoopInstaller/Extras', desc: '官方扩展包' }, + { name: 'versions', source: M + 'https://github.com/ScoopInstaller/Versions', desc: '旧版本/多版本' }, + { name: 'apps', source: M + 'https://github.com/kkzzhizhou/scoop-apps', desc: '社区大合集(聚合主流 bucket)' } + ]; + async function loadBuckets() { + const tbody = $('#tbl-bucket tbody'); + tbody.innerHTML = '加载中…'; + try { + const r = await S.listBuckets(); + $('#cnt-bucket').textContent = r.buckets.length; + tbody.innerHTML = r.buckets.map((b) => + '' + esc(b.name) + '' + + '' + esc(b.source) + '' + + '' + (b.manifests || '--') + '' + + '' + fmtDate(b.updated) + '' + + '' + ).join(''); + // tooltip 里展示真实来源,让用户知道加的是什么 + $('#known-buckets').innerHTML = KNOWN_BUCKETS.map((b) => + '' + ).join(''); + } catch (e) { + tbody.innerHTML = '读取失败:' + esc(e && e.message) + ''; + } + } + /** 输入支持三种形态:`extras`(已知名)/ git 地址(自动取名)/ `名称 地址` 两个词 */ + function addBucketByInput() { + const v = $('#bucket-input').value.trim(); + if (!v) return; + const parts = v.split(/\s+/); + let name = '', source = ''; + if (parts.length >= 2) { + name = parts[0]; + source = parts[1]; + } else if (/^https?:\/\//i.test(v)) { + source = v; + name = (v.split('/').pop() || '').replace(/\.git$/i, '').toLowerCase() || 'bucket'; + } else { + name = v; + } + runOp('bucket add ' + name, (onLine) => S.addBucket(name, source, onLine), () => { loadBuckets(); $('#bucket-input').value = ''; }); + } + $('#btn-bucket-add').onclick = addBucketByInput; + $('#bucket-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') addBucketByInput(); }); + + // ---------- 缓存 ---------- + async function loadCache() { + const tbody = $('#tbl-cache tbody'); + tbody.innerHTML = '加载中…'; + try { + const r = await S.cacheInfo(); + $('#cnt-cache').textContent = r.total.sizeText || ''; + $('#cache-total').textContent = '共 ' + r.total.files + ' 个文件,占用 ' + (r.total.sizeText || '0 B'); + tbody.innerHTML = r.apps.map((a) => + '' + esc(a.name) + '' + a.files + '' + fmtBytes(a.bytes) + '' + ).join(''); + $('#pane-cache .empty').hidden = r.apps.length > 0; + } catch (e) { + tbody.innerHTML = '读取失败:' + esc(e && e.message) + ''; + } + } + + // ---------- 行内动作(事件委托) ---------- + document.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-act]'); + if (!btn || btn.disabled) return; + const act = btn.dataset.act; + const name = btn.dataset.name || ''; + + if (act === 'update') { + runOp('update ' + name, (onLine) => S.updateApp(name, onLine), () => { loadInstalled(); if ($('#pane-status').classList.contains('active')) loadStatus(); }); + } else if (act === 'install') { + runOp('install ' + name, (onLine) => S.install(name, onLine), () => { loadInstalled(); }); + } else if (act === 'uninstall') { + if (!armConfirm(btn)) { + // 第一次点击:进入待确认态。把玩法讲清楚,尤其 Alt=彻底卸载这个隐藏档位 + setStatus('再点一次「确认?」执行卸载(保留持久化数据);Alt+点击「确认?」= 彻底卸载,连数据一起删'); + return; + } + const purge = !!e.altKey; + runOp((purge ? 'uninstall -p ' : 'uninstall ') + name, + (onLine) => S.uninstall(name, purge, onLine), + () => { loadInstalled(); loadStatus(); }); + } else if (act === 'hold') { + const held = btn.dataset.held === '1'; + runOp((held ? 'unhold ' : 'hold ') + name, (onLine) => S.holdApp(name, !held, onLine), loadInstalled); + } else if (act === 'bucket-rm') { + if (!armConfirm(btn)) return; + runOp('bucket rm ' + name, (onLine) => S.removeBucket(name, onLine), loadBuckets); + } else if (act === 'bucket-add') { + // 快捷 chips 自带国内直连源。⚠ 名字和源都要传给 scoop: + // `bucket add `,只传源会被 scoop 当已知 bucket 别名查(实测踩坑) + const source = btn.dataset.source || ''; + runOp('bucket add ' + name, (onLine) => S.addBucket(name, source, onLine), loadBuckets); + } else if (act === 'home') { + openHome(name); + } else if (act === 'folder') { + openFolder(name); + } + }); + + async function openHome(name) { + try { + const r = await S.infoApp(name); + const url = r.info.Website || r.info.Homepage || ''; + if (url) { S.openUrl(url); setStatus('已打开 ' + name + ' 主页'); } + else setStatus('这个包没有主页信息'); + } catch (e) { setStatus('打开主页失败:' + (e && e.message)); } + } + async function openFolder(name) { + try { + const p = await S.prefixOf(name); + if (p) { S.openPath(p); setStatus('已打开 ' + name + ' 目录'); } + else setStatus('取不到 ' + name + ' 的安装目录'); + } catch (e) { setStatus('打开目录失败:' + (e && e.message)); } + } + + // ---------- 顶部动作 ---------- + $('#btn-check-update').onclick = () => { + document.querySelector('.tab[data-tab="status"]').click(); + loadStatus(); + }; + $('#btn-refresh-status').onclick = loadStatus; + $('#btn-update-outdated').onclick = () => { + runOp('update *', (onLine) => S.updateApp('*', onLine), () => { loadInstalled(); loadStatus(); }); + }; + $('#btn-update-all').onclick = $('#btn-update-outdated').onclick; + $('#btn-cache-clear').onclick = () => { + runOp('cache rm *', (onLine) => S.clearCache(onLine), loadCache); + }; + $('#btn-cleanup').onclick = () => { + runOp('cleanup *', (onLine) => S.cleanup(onLine), () => { loadCache(); loadInstalled(); }); + }; + + function showWarnings(sel, warnings) { + const box = $(sel); + const list = (warnings || []).filter((w) => w && w.length < 300); + box.hidden = list.length === 0; + box.textContent = list.join('\n'); + } + + // ---------- 启动分流:有 scoop 进主界面,没有进配置页 ---------- + function enterMain(d) { + document.body.classList.remove('setup-mode'); + $('#setup').hidden = true; + const chip = $('#scoop-chip'); + chip.textContent = 'scoop v' + d.version; + chip.classList.add('ok'); + chip.classList.remove('bad'); + if (d.shims) chip.title = 'shims: ' + d.shims; + (d.warnings || []).forEach((w) => appendLog(w)); + loadInstalled(); + } + + function enterSetup(info, reason) { + document.body.classList.add('setup-mode'); + $('#setup').hidden = false; + const chip = $('#scoop-chip'); + chip.textContent = '未检测到 scoop'; + chip.classList.remove('ok'); + chip.classList.add('bad'); + chip.title = (info && info.autoFound) ? '扫描到:' + info.autoFound : ''; + + // 提示要按事实说:配置的目录现在到底还有没有效,不能只看"配置过"就断言无效 + const scan = []; + if (info && info.configuredRaw && !info.configured) { + scan.push('之前配置的位置「' + info.configuredRaw + '」现在无效了,请重新选择'); + } else if (info && info.configured) { + scan.push('已配置的位置「' + info.configured + '」仍然有效,但 scoop 检测没通过——原因见下'); + } else if (info && info.autoFound) { + scan.push('扫描到疑似安装位置「' + info.autoFound + '」——可以点「选择已有的 scoop 位置…」手动指定它的 shims 目录'); + } + if (reason) scan.push('最近一次检测失败的原因:' + reason); + const box = $('#setup-scan'); + box.hidden = scan.length === 0; + box.textContent = scan.join('\n'); + } + + function wireSetup() { + $('#btn-scoop-install').onclick = () => { + runOp('安装 Scoop', (onLine) => S.installScoop(onLine), async () => { + const d = await S.detect(); + if (d.ok) { + try { S.notify('Scoop ' + d.version + ' 安装成功'); } catch (e) {} + enterMain(d); + } else { + setStatus('安装流程结束但 scoop 仍不可用,可尝试手动选择安装位置'); + } + }); + }; + $('#btn-scoop-pick').onclick = async () => { + try { + const dir = await S.pickShimsDir(); + if (!dir) return; // 取消 + const d = await S.detect(); + if (d.ok) { + try { S.notify('已定位 scoop ' + d.version); } catch (e) {} + enterMain(d); + } else { + setStatus('已记录位置但检测仍失败:' + d.reason); + } + } catch (e) { + const msg = (e && e.message) || String(e); + setStatus(msg); + try { S.notify(msg); } catch (err) {} + } + }; + $('#btn-scoop-redetect').onclick = () => boot(); + } + + async function boot() { + let d; + try { + d = await S.detect(); + } catch (e) { + d = { ok: false, reason: String(e && e.message || e) }; + } + wireSetup(); + if (d.ok) return enterMain(d); + let info = {}; + try { info = S.getSetupInfo(); } catch (e) {} + enterSetup(info, d.reason); + } + boot(); +})(); diff --git a/plugins/ztools-scoop-manager/index.html b/plugins/ztools-scoop-manager/index.html new file mode 100644 index 000000000..4e100c899 --- /dev/null +++ b/plugins/ztools-scoop-manager/index.html @@ -0,0 +1,161 @@ + + + + + Scoop 包管理器 + + + +
+ +
+
Scoop 包管理器
+
检测中…
+
+
+ + +
+ + + + + + +
+ +
+
+ + +
+
+ + + +
名称版本更新时间操作
+ +
+ +
+ + +
+
+ 点击「检查更新」获取最新状态 +
+ + +
+
+ + + +
名称已装最新状态操作
+ +
+ + +
+ + + + + +
+
+ + +
+
快捷添加(全部为国内可直连源,无需代理;悬停查看真实地址):
+
+
+ + + +
名称来源清单数更新时间操作
+
+
+ + +
+
+ 加载中… +
+ + +
+
+ + + +
应用缓存文件数占用
+ +
+
+
+ + + + +
+ 就绪 +
+ +
+ + + + diff --git a/plugins/ztools-scoop-manager/logo.png b/plugins/ztools-scoop-manager/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7d4a9016770794e4bbf05a6396cad0449424519a GIT binary patch literal 3066 zcmZuzX;_m<)~&o*AOYDIWetlFP}T-S772n4h|+u{h=4*IP_acsP&Spk3Dd1CO=3_1 zTL~i2Y_chW0wySuR#9*o0YM}Zl|_XJNgEO%6Q<{x`F>3OsayBCRrlOe=RQ?AA;JFZ za0WO4z`DQyzfb@W3KId8F^Xw_a`j;VRJsHGe8}n7zKl;WEW>oUUuV_or4-0hXezqN z=!|!c({#l3scvbRV<_z;mlHYzQePiROWmih>RA3#7-gE8ntJd^>S3a3zV~DYWBY=s z5TK~dzB@{0D~n3sUY%>GO_}$aDxKdnquf1nKkUHN;Jx1x!}ZaM{QqkVRE4{V{`4>{ zs^Nj_n6_^R9{c_25!7_E&Zjrf?3uB}wB}R%?KtN17aF1q$C*A((;4feAH3&&SHpa? zrKYeu;s&0139+YocB>*|bIOWh`RtFg1;iJ9C93t7kaqU9O%iNod=LhS3@kgcT`1@7 zH#vI!-f*IMh(}XI_YeF>q2}wY_CPjDJNXuu7iIdnH>qQ(h05f0ha-jT@S<4aZ+e}p^+K}w?M$+{j`)_(d+$%W9uXlV^6Kk;z^J99MB564vJ;Gl+}){znMPT zgnc(X@AL!e&<*-2dwe=NX&IM74Xne_v-UccAHD!oXT?XmT(_KRJB5<8L#k%vy89r*= z-gBd?@gAmEo1BMGy4<*k!UC)w2wZ*jp^PXP@upJkMqgE3+9G zXB;4yx8L@si$x&b|DYDJVJ387^ky>R)bwRZ_jjXJgITJDMEZ&_D>y|TkEkiDfe5Du z0t$Sbtj$BwA#Gb6|A?#UZPSgm_K&?k;&`F8)Z@?%((w0X>1bikp*gVo4;P{{hVx!> z--P|f%giWdfdmhJ+oj8KSCT*Tc=GLF>2T6DV^szzzuEN$zx}SkH&qeHpEe*jM;JDS zTfzZ$SyXlhW!7oVj;De>fxPl?V{V#KOv{F4fq)~*KKHS|GW6v>OL4_Kv@AM(6)qgb zz+%2B&yl(K<`LUYsiFG53fQm>O)ZcW<^e4JbRK!Q?#pF+aeM2X#zte#pup~O*ZTov z$n-n?#={*kjq;`??ZlMp)MH#7V#)sOiD#=3zL0YD^H*v{yy5@=$BKZ?(vV8{_~$7@ zhD%++H1h90XyEoeq+Btht7g=%699IlTg$uV8rlBzll@l&dbp`8G(wrk(7pSlV0|T5 z2PEaA6dTm>%=1}6g?X_Rre1*Sn6;srCN@2JVj>qwrlgo5`e@uXS;B=@hXcC0S+uzpHu`$+?S+4VrZ6&w%)^L#Yx}6PZFSTdN%OGH6 zfw#>$Y*iw%5VG^VK`94^HhkI@_sI1a^5ho?*)xbNKAEGtKmv%rlSr zeY%%IM)Ihcpy>xU!v1%z6!YfB)!TOsWwY$ej`p7w?DV>W5uQ<*%MgO=Imz*=Qi#P; zLGTdbROcqyFYL4^6*%!{p11hi(m&?h!5a5hVTWHZl&xh zsCc$0^-b9ND)xScXL_V1F#C2*#u$vE365_?kiB1i7FTxRid(xz!Uc9?K`RkOKfkzr zv>IsTT}FKz53aW)&NL#u*Hv+?+*+4i`BrYWE3SA2-&U+z$98?X3w1$-79hL;FYd;y z=~fFa>*!D3?0_k_ZKMVG>%BnnqvaSiU4^NRqWQe}k+nn79=(fzqK<|q?UCE4_z}yF~rr|1^$l=5T9c{?#1(7VKRY3 zb{@y`FA+CoYY~msQehQpA>jKc7)rSkmq)(0ILOK6G@gxK7G`cQW%B;hbAaH`teSlq zG|^NwP^2~Ze?jJ|a(AJb(u*~@(J|YebREM!5>)dSx7_JOIilAz`q#xsGLou!!$A*k z{7biMK1jBJO++Uzi^`S2t73VS%!w)5g?8gD>1h4;^lC+~Z&?GZMMTB8ulnRak2^2W zGhv{zqu+q0aKwK1)I3oXq^uKjeKXwsW##SI{MpXU;f3vHw8i)blFg@VQ9G{No7_|f z=N?*u(5)0GO+gKv40Hf|6Gv&${MvhR4h8-}5Y!$+IwWW_oj?e~1n~DwY5ncZ`%L)! zi!S@6KjEk%_9c6{MLm*Y0^;>t5gKxsZL;IR|_oIdEBTcGDy1>!ec=uox_TI z9DRV4G}G5%{WD~FJ41T+eO-K^2+XFo%u&F-QL@JJY^f4a3s(9y*Df`XbBDB5w|a~1 zcgDn0*P;_7G5Y@diP5pm*exs+v&8mLccVEXpQ+5Ov;B$I*r3!j+&%J%kI+l7R$I-@ z_2~=4UaPVPQFmWVE7n(OeT66+6-&sft;?T(Ol38Gm*!ob9I{BD;dBws zN)JBr8>@}?3H*|BO%Ri#(anYui&>=+OKd(5yA!H*H>Pgls8>FMX&vfqaC z8N~((MSc!BJ+K`u(M#yRTP@Ok11P0uU0X7)_H`m!WvkM&_V4u|osWYXFeT`f_Jp(q l0~X|o@%UfgWdJ{9$&qem>)NQu9_d;{;Lc#bh8-08KLI_pbfEwM literal 0 HcmV?d00001 diff --git a/plugins/ztools-scoop-manager/plugin.json b/plugins/ztools-scoop-manager/plugin.json new file mode 100644 index 000000000..664fbf259 --- /dev/null +++ b/plugins/ztools-scoop-manager/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "ztools-scoop-manager", + "title": "Scoop 包管理器", + "description": "Scoop 图形化管理面板:已装软件一览、一键更新/卸载、搜索安装、Bucket 管理与缓存清理,全部走本地 scoop 命令,无需任何账号", + "author": "zhanglei", + "version": "1.0.0", + "main": "index.html", + "preload": "preload.js", + "logo": "logo.png", + "platform": ["win32"], + "pluginSetting": { + "single": true, + "height": 620 + }, + "features": [ + { + "code": "scoop", + "explain": "打开 Scoop 包管理器(已装软件 / 更新 / 搜索安装 / Bucket / 缓存)", + "icon": "logo.png", + "cmds": [ + "scoop", + "包管理", + "软件管理", + "更新软件", + "卸载软件", + "包管理器" + ] + } + ] +} diff --git a/plugins/ztools-scoop-manager/preload.js b/plugins/ztools-scoop-manager/preload.js new file mode 100644 index 000000000..95de0626b --- /dev/null +++ b/plugins/ztools-scoop-manager/preload.js @@ -0,0 +1,993 @@ +/** + * Scoop 包管理器 · preload(ZTools 的 Node 运行环境,CommonJS) + * + * 职责:封装 scoop 命令行调用(查询走 JSON 管道,写操作走流式日志)、 + * 输入消毒(应用名/bucket 名最终要拼进 powershell -Command,绝不能放任意字符串进壳)。 + * 页面(index.html)没有 Node,只能通过本文件挂在 window.services 上的函数通信。 + * + * 为什么查询命令都要套 `| ConvertTo-Json`: + * scoop 本身是 PowerShell 脚本,`scoop list` / `scoop status` 等输出的表格 + * 会按宿主控制台宽度自动排版,列宽随内容漂移,还有 ANSI 色码和 WARN 行混排。 + * 实测(本机 scoop v0.6.0)`scoop list | ConvertTo-Json`、`scoop status | ConvertTo-Json`、 + * `scoop bucket list | ConvertTo-Json`、`scoop search x | ConvertTo-Json`、 + * `scoop info x | ConvertTo-Json` 都能输出结构化对象,是唯一稳的解析路径。 + * JSON 之前的输出("Installed apps:"、git fatal、WARN 行)统一切走 warnings 通道展示。 + */ + +function ZT() { + if (typeof window !== 'undefined') return window.ztools || window.utools || {}; + return globalThis.ztools || globalThis.utools || {}; +} + +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// ---------- 持久化(dbStorage 简单键值) ---------- +function dbGet(key) { + try { const z = ZT(); return z.dbStorage ? z.dbStorage.getItem(key) : null; } catch (e) { return null; } +} +function dbSet(key, val) { + try { const z = ZT(); return z.dbStorage ? z.dbStorage.setItem(key, val) : null; } catch (e) { return null; } +} +function dbDel(key) { + try { const z = ZT(); return z.dbStorage ? z.dbStorage.removeItem(key) : null; } catch (e) { return null; } +} +const K_SHIMS = 'scoop.shimsDir'; // 用户手动指定的 shims 目录(scoop 不在 PATH 时的兜底) + +// ---------- scoop 环境定位 ---------- +// 宿主(ZTools)的 PATH 未必包含 scoop 的 shims 目录(比如先装了 scoop 后装的 ZTools +// 且 PATH 没刷新,或 scoop 装在非默认盘)。三层兜底: +// ① 用户在配置页手动指定的目录(K_SHIMS) +// ② 常见安装位置扫描(%SCOOP%、%USERPROFILE%\scoop、C:\scoop、D:\scoop) +// ③ 都没有 → 靠进程自身 PATH 直呼 scoop +// 本插件运行期补装的工具(如 git)目录也追加在这里,见 extraPathDirs。 +let extraPathDirs = []; +function configuredShimsDir() { + const v = String(dbGet(K_SHIMS) || '').trim(); + if (v && fs.existsSync(path.join(v, 'scoop.ps1'))) return v; + return ''; +} +function findExistingShimsDir() { + const env = process.env || {}; + const candidates = []; + if (env.SCOOP) candidates.push(env.SCOOP + '\\shims'); + if (env.USERPROFILE) candidates.push(env.USERPROFILE + '\\scoop\\shims'); + candidates.push('C:\\scoop\\shims', 'D:\\scoop\\shims'); + for (const c of candidates) { + try { if (c && fs.existsSync(path.join(c, 'scoop.ps1'))) return c; } catch (e) {} + } + return ''; +} +function activeShimsDir() { + return configuredShimsDir() || findExistingShimsDir(); +} +/** 子进程环境:把定位到的 shims 目录插到 PATH 最前,让 `scoop` 一定能被解析 */ +function childEnv() { + const shims = activeShimsDir(); + const parts = extraPathDirs.slice(); + if (shims) parts.push(shims); + const pre = parts.join(';'); + if (!pre) return process.env; + return Object.assign({}, process.env, { PATH: pre + ';' + (process.env.PATH || '') }); +} + +// ---------- 输入消毒 ---------- +// 应用名/bucket 名会拼进 powershell -Command 字符串。scoop 官方名集是 +// [\w.@-](如 7zip@16.04、7zip-beta_DoveBoy),超出这个集合的一律拒绝, +// 这是防 PS 注入的唯一防线,不要放宽。 +const APP_NAME_RE = /^[\w.@-]{1,100}$/; +const BUCKET_NAME_RE = /^[\w.-]{1,64}$/; +// bucket 来源:远端 git URL、本机/网络路径、或官方短名。scoop bucket add 三种都认。 +const URL_RE = /^https?:\/\/\S{1,300}$/; +const LOCAL_PATH_RE = /^[A-Za-z]:[\\/]\S{0,299}$/; + +function assertApp(name) { + const s = String(name || '').trim(); + if (!APP_NAME_RE.test(s)) throw new Error('非法的应用名:' + s); + return s; +} +function assertBucketName(name) { + const s = String(name || '').trim(); + if (!BUCKET_NAME_RE.test(s)) throw new Error('非法的 bucket 名:' + s); + return s; +} +function assertBucketSource(arg) { + const s = String(arg || '').trim(); + if (BUCKET_NAME_RE.test(s) || URL_RE.test(s) || LOCAL_PATH_RE.test(s)) return s; + throw new Error('bucket 来源只能是名称、https(s) 链接或本机路径'); +} + +// ---------- PowerShell 调用 ---------- +const PS_BASE = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command']; +// 统一前缀:UTF-8 输出 + 关掉进度条(否则 install 会刷 ProgressRecord 噪音) +const PS_PREFIX = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; $ProgressPreference='SilentlyContinue'; "; + +function scoopScript(scoopArgs, { json = false } = {}) { + const a = scoopArgs.join(' '); + if (json) return PS_PREFIX + 'scoop ' + a + ' | ConvertTo-Json -Depth 5 -Compress'; + // 流式:合并 stderr 到 stdout 并压平 ErrorRecord,让日志面板按行实时滚动 + return PS_PREFIX + 'scoop ' + a + ' 2>&1 | ForEach-Object { "$_" }'; +} + +/** + * 查询类命令:拿到完整输出后抽取 JSON。 + * 返回 { data, warnings } —— warnings 是 JSON 前缀里的告警行 + * (git fatal / "WARN Scoop bucket(s) out of date" 等),页面选择性展示。 + * + * allowEmpty:结果集可能为空的命令(status 全最新 / search 无结果 / cache 已清空), + * PowerShell 5.1 对空数组管道 ConvertTo-Json 会输出空串,拿不到任何 JSON—— + * 打开这个开关后按 { data: [], warnings: 全部输出行 } 返回,而不是报错。 + */ +function runScoopJson(scoopArgs, { timeout = 150000, allowEmpty = false } = {}) { + return new Promise((resolve, reject) => { + const child = spawn('powershell.exe', PS_BASE.concat(scoopScript(scoopArgs, { json: true })), { + windowsHide: true, + env: childEnv() + }); + let out = ''; + let err = ''; + let done = false; + const timer = setTimeout(() => { + try { child.kill(); } catch (e) {} + finish(new Error('scoop 查询超时(' + Math.round(timeout / 1000) + 's)')); + }, timeout); + + function finish(e, val) { + if (done) return; + done = true; + clearTimeout(timer); + e ? reject(e) : resolve(val); + } + + child.stdout.on('data', (d) => { out += d.toString('utf8'); }); + child.stderr.on('data', (d) => { err += d.toString('utf8'); }); + child.on('error', (e) => finish(new Error('无法启动 powershell.exe:' + e.message))); + child.on('close', (code) => { + if (done) return; + try { + const r = extractJson(out || err); + finish(null, r); + } catch (e) { + if (allowEmpty) { + // ⚠ stderr 必须一起纳入:"命令不存在"类错误走的是错误流(stderr), + // 只看 stdout 的话 detect 永远看不到报错文本(1.4.2 修了个寂寞,用户实测) + const raw = String(out || '') + (err ? '\n' + String(err) : ''); + finish(null, { + data: [], + warnings: raw.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).slice(-5), + empty: true, + code: code, + raw: raw + }); + return; + } + // 无 JSON 且退出码非 0:多半是命令本身失败(比如 scoop 不存在) + finish(new Error(trimLog((out + '\n' + err)) || ('scoop 退出码 ' + code))); + } + }); + }); +} + +/** 从混杂输出中截取 JSON;前缀行作为 warnings 返回 */ +function extractJson(text) { + const lines = String(text || '').split(/\r?\n/); + let start = -1; + for (let i = 0; i < lines.length; i++) { + const t = lines[i].trim(); + // ⚠ 只认 [ 或 { 开头。带引号的 JSON 标量串(scoop prefix 输出路径)不算—— + // 它走 runScoopText,不进这里;放宽反而会把日志里的引号行误判成数据。 + if (t.startsWith('[') || t.startsWith('{')) { start = i; break; } + } + if (start < 0) throw new Error('输出里没有找到 JSON'); + const warnings = []; + for (let i = 0; i < start; i++) { + const t = lines[i].trim(); + // "Installed apps:" 这种表头不算告警,git fatal / WARN 才值得给用户看 + if (t && !/^Installed apps:$/.test(t)) warnings.push(t); + } + let data; + try { + data = JSON.parse(lines.slice(start).join('\n')); + } catch (e) { + throw new Error('JSON 解析失败:' + String(e.message).slice(0, 120)); + } + return { data, warnings }; +} + +/** scoop 的日期有三种形态:/Date(ms)/、DateTime 本地化串、普通字符串 */ +function parseScoopDate(v) { + if (v == null || v === '') return 0; + if (typeof v === 'number') return v; + if (typeof v === 'object') { + const m = /\/Date\((\d+)\)\//.exec(v.value || v.DateTime || ''); + if (m) return Number(m[1]); + return 0; + } + const m = /\/Date\((\d+)\)\//.exec(String(v)); + if (m) return Number(m[1]); + const t = Date.parse(String(v)); + return isFinite(t) ? t : 0; +} + +function trimLog(text) { + return String(text || '').replace(/\r/g, '').split('\n') + .map((l) => l.trim()).filter(Boolean).slice(-8).join('\n').slice(0, 800); +} + +// ---------- 长任务(install/update/uninstall/cleanup/bucket 增删) ---------- +// 全局同时只允许一个写操作:scoop 自己没有并发锁,两个 install 并跑会互相踩缓存。 +let currentChild = null; +let currentLabel = ''; + +/** 通用 PowerShell 流式任务:长命令(装 scoop、install/update)共用,互斥 + 行回调 */ +function streamPowerShell(psScript, label, onLine) { + return new Promise((resolve, reject) => { + if (currentChild) throw new Error('已有任务在执行:' + currentLabel); + const child = spawn('powershell.exe', PS_BASE.concat(psScript), { + windowsHide: true, + env: childEnv() + }); + currentChild = child; + currentLabel = label; + const lines = []; + const push = (buf) => { + String(buf).split(/\r?\n/).forEach((l) => { + const t = l.trim(); + if (!t) return; + lines.push(t); + try { if (onLine) onLine(t); } catch (e) {} + }); + }; + const finish = (e) => { + if (currentChild === child) { currentChild = null; currentLabel = ''; } + e ? reject(e) : resolve({ lines }); + }; + child.stdout.on('data', push); + child.stderr.on('data', push); + child.on('error', (e) => finish(new Error('无法启动 powershell.exe:' + e.message))); + child.on('close', (code) => { + if (code === 0) return finish(null); + const tail = lines.slice(-6).join('\n'); + finish(new Error(label + ' 失败(退出码 ' + code + ')\n' + tail)); + }); + }); +} + +function runScoopStream(scoopArgs, label, onLine) { + return streamPowerShell(scoopScript(scoopArgs), label, onLine); +} + +function stopOp() { + if (currentChild) { + try { currentChild.kill(); } catch (e) {} + currentChild = null; + currentLabel = ''; + return true; + } + return false; +} +function isBusy() { return !!currentChild; } +function busyLabel() { return currentLabel; } + +// 进程退出兜底:绝不让插件把 powershell/scoop 留成孤儿 +try { + process.on('exit', () => { try { stopOp(); } catch (e) {} }); +} catch (e) {} + +/** + * 纯文本命令(scoop prefix):输出是一个裸路径,不是 JSON,也不该套 ConvertTo-Json。 + * 拿完整输出 trim 后直接返回字符串。 + */ +function runScoopText(scoopArgs, { timeout = 30000 } = {}) { + return new Promise((resolve, reject) => { + const child = spawn('powershell.exe', PS_BASE.concat(PS_PREFIX + 'scoop ' + scoopArgs.join(' ')), { + windowsHide: true, + env: childEnv() + }); + let out = ''; + let err = ''; + let done = false; + const timer = setTimeout(() => { + try { child.kill(); } catch (e) {} + finish(new Error('scoop 查询超时')); + }, timeout); + function finish(e, val) { + if (done) return; + done = true; + clearTimeout(timer); + e ? reject(e) : resolve(val); + } + child.stdout.on('data', (d) => { out += d.toString('utf8'); }); + child.stderr.on('data', (d) => { err += d.toString('utf8'); }); + child.on('error', (e) => finish(new Error('无法启动 powershell.exe:' + e.message))); + child.on('close', (code) => { + if (done) return; + const text = (out || err).trim(); + if (!text) return finish(new Error('scoop 没有输出(退出码 ' + code + ')')); + finish(null, text.split(/\r?\n/)[0].trim()); // 只取第一行,防末尾混入告警 + }); + }); +} + +// ---------- 业务查询 ---------- +/** + * 环境检测(启动时调用)。不再抛异常——检测结果交给页面分流: + * ok=true 进主界面,ok=false 进配置页。detectInfo 里带上定位到的 shims 目录, + * 配置页据此区分「装了但不在 PATH」和「没装」。 + */ +/** 判定输出是否是"scoop 命令不存在"类错误(中英文 PowerShell 都覆盖) */ +function looksLikeCommandNotFound(text) { + return /is not recognized|不被识别|无法将.*识别为|is not an internal or external command|CommandNotFoundException|不是内部或外部命令/i.test(String(text || '')); +} + +/** + * 根据 --version 的结果分类:'online' | 'not-found'(纯函数,好测)。 + * - 有 JSON 数据 / 输出里有版本号 → 在线(新装无 git 的机器属于这种,版本行在宿主流); + * - 空结果 + 命令不存在报错 → 未安装; + * - 空结果 + 退出码非 0 + 连版本号都没有 → 也判未安装(覆盖未知体裁的报错)。 + */ +function classifyDetect(r) { + const text = Array.isArray(r.data) ? r.data.join('\n') : String(r.data || ''); + const all = text + '\n' + (r.warnings || []).join('\n') + '\n' + (r.raw || ''); + const versionFound = /v\d+\.\d+\.\d+/.test(all); + if (r.empty && (looksLikeCommandNotFound(all) || (r.code !== 0 && !versionFound))) { + return 'not-found'; + } + return 'online'; +} + +async function detect() { + let r; + try { + // ⚠ 必须 allowEmpty:刚装好的机器上没有 git,scoop --version 的 bucket 摘要 + // 管道输出为空,PowerShell 5.1 空数组 | ConvertTo-Json 是空串—— + // 没有这个开关,新装机第一次检测就会误判"scoop 不可用"(实测踩坑)。 + // 版本号在宿主流的 warnings 里,照样能拿到。 + r = await runScoopJson(['--version'], { timeout: 30000, allowEmpty: true }); + } catch (e) { + return { + ok: false, + reason: String(e && e.message || e), + shims: activeShimsDir(), + configured: configuredShimsDir(), + autoFound: findExistingShimsDir() + }; + } + // ⚠ allowEmpty 的代价:连"命令不存在"也会走空结果通道(用户实测:卸载 scoop 后 + // 徽标显示 v未知版本还进了主界面)。分类判定见 classifyDetect。 + if (classifyDetect(r) === 'not-found') { + return { + ok: false, + reason: 'scoop 命令不存在(可能已被卸载,或 shims 不在 PATH)', + shims: activeShimsDir(), + configured: configuredShimsDir(), + autoFound: findExistingShimsDir() + }; + } + const text = Array.isArray(r.data) ? r.data.join('\n') : String(r.data || ''); + const all = text + '\n' + (r.warnings || []).join('\n') + '\n' + (r.raw || ''); + // ⚠ 这版 scoop 的 --version 有两个坑: + // ① 版本行("v0.6.0 - Released at ...")走 Write-Host 直达宿主流,不在管道里, + // 所以 ConvertTo-Json 拿不到它,只能从 warnings 前缀里捞; + // ② 管道里反而是各 bucket 的更新摘要(如 "tombi: Update to version 1.6.1"), + // 宽松的 \d+\.\d+\.\d+ 会把 1.6.1 当成 scoop 版本,必须锚定 v 前缀。 + const m = /v(\d+\.\d+\.\d+)/.exec(all); + const shims = activeShimsDir(); + return { + ok: true, + version: m ? m[1] : '未知版本', + raw: text, + warnings: r.warnings, + shims: shims, + configured: configuredShimsDir(), + autoFound: findExistingShimsDir(), + root: shims ? path.dirname(shims) : '' + }; +} + +// ---------- 环境配置(无 scoop 机器的自举) ---------- +// 常见本地代理端口(与 ztools-weather 同一套探测清单,顺序按实际命中率排)。 +// 直连 github.com 不通时,装 Scoop 会自动逐个试这些代理。 +const LOCAL_PROXY_CANDIDATES = [ + 'http://127.0.0.1:7891', 'http://127.0.0.1:7897', 'http://127.0.0.1:7890', + 'http://127.0.0.1:10809', 'http://127.0.0.1:1080' +]; + +// 国内加速镜像:scoop.201704.xyz 是社区 GitHub 反代(本插件用户的 apps bucket +// 也在用同一服务)。用法是在原 GitHub URL 前拼上前缀。 +// 实测:https://scoop.201704.xyz/https://github.com/ScoopInstaller/Scoop/archive/master.zip +// 返回 HTTP 200 + PK 魔数,可用;而 Gitee 的 archive zip 已全面 404,不能用。 +const MIRROR_PREFIX = 'https://scoop.201704.xyz/'; + +/** + * 组装一次安装尝试的 PowerShell 脚本。 + * @param proxy '' = 直连;'http://127.0.0.1:xxxx' = 走该代理 + * @param mirror true = 官方安装器 + 把脚本内 GitHub 下载地址打上镜像前缀补丁, + * 并在 get.scoop.sh 拉不到时改从镜像代理拉安装器本体 + */ +function installScript(proxy, mirror) { + const proxyArgs = proxy ? " -Proxy '" + proxy + "'" : ''; + const fetchInstaller = mirror + ? // get.scoop.sh 挂了就走镜像代理拉 raw install.ps1(双保险) + 'try { irm get.scoop.sh -OutFile $installer' + proxyArgs + ' } catch { irm "' + + MIRROR_PREFIX + 'https://raw.githubusercontent.com/ScoopInstaller/Install/master/install.ps1" -OutFile $installer' + proxyArgs + ' }' + : 'irm get.scoop.sh -OutFile $installer' + proxyArgs; + // 给安装器内部所有单引号包裹的 GitHub 地址加镜像前缀——正好覆盖 + // $SCOOP_PACKAGE_REPO / $SCOOP_MAIN_BUCKET_REPO / 两个 .git 仓库地址(实测它们就是 + // 下载失败的地方),注释和提示文案里的裸 URL 不受影响。 + const patch = mirror + ? '$t = [IO.File]::ReadAllText($installer); ' + + "$t = $t.Replace(\"'https://github.com/\", \"'" + MIRROR_PREFIX + "https://github.com/\"); " + + '[IO.File]::WriteAllText($installer, $t, [Text.UTF8Encoding]::new($true)); ' + : ''; + return PS_PREFIX + + // ⚠ PS5.1 的 .NET 默认不开 TLS 1.2,而 GitHub 强制要求—— + // "基础连接已经关闭: 发送时发生错误" 十有八九是它 + '[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ' + + // 执行策略覆盖告警是语句终止错误,-ErrorAction 压不住,必须 try/catch(实测) + 'try { Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force -ErrorAction Stop } catch {}; ' + + '$admin = [Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())' + + '.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator); ' + + '$installer = Join-Path $env:TEMP "ztools-scoop-install.ps1"; ' + + fetchInstaller + '; ' + + patch + + 'if ($admin) { & $installer -RunAsAdmin' + proxyArgs + ' } else { & $installer' + proxyArgs + ' }'; +} + +/** 上次安装半途而废会在 %USERPROFILE%\scoop 留下残骸,下次再装会被 + * "exists and is not empty" 挡住(实测踩坑)。改名为 .bak-时间戳 保底不删。 */ +function renamePartialScoopDir(onLine) { + const up = (process.env.USERPROFILE || '').trim(); + if (!up) return; + const dir = path.join(up, 'scoop'); + try { + if (!fs.existsSync(dir)) return; + // 里面已经有完整的 shims 结构 → 其实是可用的 scoop,不能动 + if (fs.existsSync(path.join(dir, 'shims', 'scoop.ps1'))) return; + const bak = dir + '.bak-' + new Date().toISOString().replace(/[:.]/g, '-'); + fs.renameSync(dir, bak); + try { onLine('>> 检测到上次失败留下的 ' + dir + ',已改名为 ' + bak + '(没有删除)'); } catch (e) {} + } catch (e) { + try { onLine('>> 残留目录改名失败(不继续挡路就不管):' + (e && e.message)); } catch (e2) {} + } +} + +/** TCP 探测本地代理端口是否在监听,800ms 不通就算关 */ +function portOpen(proxy, timeout = 800) { + return new Promise((resolve) => { + const net = require('net'); + let u; + try { u = new URL(proxy); } catch (e) { return resolve(false); } + const sock = net.connect(Number(u.port) || 80, u.hostname); + const done = (ok) => { try { sock.destroy(); } catch (e) {} resolve(ok); }; + const timer = setTimeout(() => done(false), timeout); + sock.once('connect', () => { clearTimeout(timer); done(true); }); + sock.once('error', () => { clearTimeout(timer); done(false); }); + }); +} + +/** + * 一键安装 Scoop。尝试顺序(每次都以「能否定位到 scoop.ps1」收口,绝不信任退出码): + * ① 官方安装器 + GitHub 下载地址打镜像前缀(国内直连,无需梯子) + * ② 官方源直连(TLS 1.2 修好后有概率直接通) + * ③ 本机在监听的代理端口 + 官方源 + */ +async function installScoop(onLine) { + // 已有可用的 scoop 就不折腾安装了,直接登记并返回 + const existing = findExistingShimsDir(); + if (existing) { + dbSet(K_SHIMS, existing); + try { onLine('>> 本机已有可用的 scoop(' + existing + '),跳过安装'); } catch (e) {} + return { shims: existing }; + } + renamePartialScoopDir(onLine); + + const proxies = []; + for (const p of LOCAL_PROXY_CANDIDATES) { + if (await portOpen(p)) proxies.push(p); + } + const attempts = [ + { mirror: true, proxy: '', label: '安装 Scoop(国内镜像)' }, + { mirror: false, proxy: '', label: '安装 Scoop(官方源直连)' } + ].concat(proxies.map((p) => ({ mirror: false, proxy: p, label: '安装 Scoop(代理 ' + p + ')' }))); + + let lastErr = null; + for (const a of attempts) { + try { onLine('>> 尝试:' + a.label); } catch (e) {} + try { + await streamPowerShell(installScript(a.proxy, a.mirror), a.label, onLine); + const found = findExistingShimsDir(); + if (found) { + dbSet(K_SHIMS, found); + return { shims: found }; + } + lastErr = new Error('安装脚本执行完了,但没有找到 scoop(shims 未生成)'); + } catch (e) { + lastErr = e; + } + } + throw new Error( + '所有安装方式都失败了(国内镜像 / 官方直连' + (proxies.length ? ' / 本机代理' : ',且本机没探测到在运行的代理') + + ')。最后一次错误:' + (lastErr && lastErr.message ? lastErr.message.split('\n')[0] : lastErr) + ); +} + +/** 手动选择已有的 shims 目录(scoop 装在非默认位置且不在 PATH 时的兜底) */ +function pickShimsDir() { + const z = ZT(); + if (!z.showOpenDialog) throw new Error('宿主不支持打开目录选择器'); + let picked = z.showOpenDialog({ + title: '选择 scoop 的 shims 目录(里面应有 scoop.ps1)', + properties: ['openDirectory'] + }); + if (picked && !Array.isArray(picked) && picked.filePaths) picked = picked.filePaths; + const dir = Array.isArray(picked) ? picked[0] : picked; + if (!dir) return null; // 用户取消 + if (!fs.existsSync(path.join(dir, 'scoop.ps1'))) { + throw new Error('该目录下没有 scoop.ps1——请选择 shims 目录,例如 D:\\scoop\\shims'); + } + dbSet(K_SHIMS, dir); + return dir; +} +function clearShimsDir() { dbDel(K_SHIMS); return true; } +/** 配置页展示用:当前的自定义配置与扫描结果 */ +function getSetupInfo() { + return { + configured: configuredShimsDir(), + configuredRaw: String(dbGet(K_SHIMS) || ''), + autoFound: findExistingShimsDir() + }; +} + +/** 已安装列表。Info 列可能是 "Global install"、"Held package"、"Install failed" 等,原样带给页面标色 */ +async function installed() { + const r = await runScoopJson(['list']); + const arr = Array.isArray(r.data) ? r.data : []; + const seen = {}; + const apps = []; + for (const x of arr) { + const row = { + name: String(x.Name || ''), + version: String(x.Version || ''), + source: String(x.Source || ''), + updated: parseScoopDate(x.Updated), + info: String(x.Info || '') + }; + if (!row.name) continue; + // 实测同一行会整条重复(scoop 的安装记录库里有冗余),完全相同的只留一条; + // 同名不同 info 的(全局+用户各装一份)是真实状态,保留。 + const key = [row.name, row.version, row.source, row.info].join('|'); + if (seen[key]) continue; + seen[key] = 1; + apps.push(row); + } + apps.sort((a, b) => a.name.localeCompare(b.name)); + return { apps, warnings: r.warnings }; +} + +/** 可更新清单 + 异常包(Manifest removed / Install failed)。全部最新时输出为空,须 allowEmpty */ +async function status() { + const r = await runScoopJson(['status'], { timeout: 240000, allowEmpty: true }); + const arr = Array.isArray(r.data) ? r.data : []; + const seen = {}; + const outdated = []; + const broken = []; + for (const x of arr) { + const name = String(x.Name || ''); + if (!name || seen[name]) continue; // 实测偶发整表重复,按名去重 + seen[name] = true; + const item = { + name, + installed: String(x['Installed Version'] || ''), + latest: String(x['Latest Version'] || ''), + missing: String(x['Missing Dependencies'] || ''), + info: String(x.Info || '') + }; + if (item.info.indexOf('removed') >= 0 || item.info.indexOf('Install failed') >= 0) broken.push(item); + else outdated.push(item); + } + return { outdated, broken, warnings: r.warnings }; +} + +async function searchApps(keyword) { + const q = String(keyword || '').trim(); + if (!q) return { results: [], warnings: [] }; + if (!/^[\w.@ -]{1,60}$/.test(q)) throw new Error('搜索词只能包含字母、数字、空格和 . @ _ -'); + const r = await runScoopJson(['search', q], { timeout: 240000, allowEmpty: true }); + const arr = Array.isArray(r.data) ? r.data : []; + const seen = {}; + const results = []; + for (const x of arr) { + const name = String(x.Name || ''); + if (!name || seen[name + '|' + x.Source]) continue; + seen[name + '|' + x.Source] = true; + results.push({ + name, + version: String(x.Version || ''), + source: String(x.Source || ''), + binaries: String(x.Binaries || '') + }); + } + return { results, warnings: r.warnings }; +} + +async function infoApp(name) { + const app = assertApp(name); + const r = await runScoopJson(['info', app], { timeout: 60000 }); + const d = (r.data && !Array.isArray(r.data)) ? r.data : {}; + const flat = {}; + Object.keys(d).forEach((k) => { + let v = d[k]; + if (v && typeof v === 'object') v = v.DateTime || v.value || JSON.stringify(v); + if (Array.isArray(v)) v = v.join(', '); + flat[k] = v == null ? '' : String(v); + }); + return { info: flat, warnings: r.warnings }; +} + +async function listBuckets() { + const r = await runScoopJson(['bucket', 'list']); + const arr = Array.isArray(r.data) ? r.data : []; + return { + buckets: arr.map((b) => ({ + name: String(b.Name || ''), + source: String(b.Source || ''), + updated: parseScoopDate(b.Updated), + manifests: Number(b.Manifests) || 0 + })), + warnings: r.warnings + }; +} + +async function cacheInfo() { + // 输出形如:前缀行 "Total: 240 files, 30.2 GB" + JSON 数组; + // 缓存为空时连 JSON 数组都没有(空数组 | ConvertTo-Json 输出空串),须 allowEmpty + const r = await runScoopJson(['cache', 'show'], { allowEmpty: true }); + const totalLine = (r.warnings.concat()).reverse().find((l) => /^Total:/.test(l)) || ''; + const m = /Total:\s*(\d+)\s*files?,\s*([0-9.]+\s*[KMG]B?)/.exec(totalLine); + const arr = Array.isArray(r.data) ? r.data : []; + const byApp = {}; + for (const x of arr) { + const name = String(x.Name || ''); + if (!name) continue; + if (!byApp[name]) byApp[name] = { name, files: 0, bytes: 0 }; + byApp[name].files += 1; + byApp[name].bytes += Number(x.Length) || 0; + } + return { + total: { files: m ? Number(m[1]) : arr.length, sizeText: m ? m[2] : '' }, + apps: Object.values(byApp).sort((a, b) => b.bytes - a.bytes), + warnings: r.warnings + }; +} + +/** 应用安装目录(scoop prefix),用于"打开目录"。输出是裸路径,走纯文本通道 */ +async function prefixOf(name) { + const app = assertApp(name); + const p = await runScoopText(['prefix', app]); + return p; +} + +// ---------- 清单镜像化(解决"装软件"这一层的网络问题) ---------- +// bucket 加好后,清单里的下载地址仍是硬编码的 GitHub(releases/raw 等), +// 没梯子的机器会死在下载上(实测:aria2 → "远程主机强迫关闭了一个现有的连接")。 +// 方案:读本地 bucket 里的清单 → 深度遍历把 GitHub 地址打上镜像前缀 → +// 用改好的本地清单执行 scoop install(scoop 原生支持按路径装清单)。 +// 镜像代理的是原字节流,hash 校验不受影响;非 GitHub 地址一律不动。 +const GITHUB_URL_PREFIXES = [ + 'https://github.com/', + 'https://raw.githubusercontent.com/', + 'https://objects.githubusercontent.com/', + 'https://codeload.github.com/', + 'https://api.github.com/' +]; + +function mirrorize(s) { + for (const p of GITHUB_URL_PREFIXES) { + if (s.indexOf(p) === 0) return MIRROR_PREFIX + s; + } + return s; +} + +function deepMirrorize(v) { + if (typeof v === 'string') return mirrorize(v); + if (Array.isArray(v)) return v.map(deepMirrorize); + if (v && typeof v === 'object') { + const out = {}; + for (const k of Object.keys(v)) out[k] = deepMirrorize(v[k]); + return out; + } + return v; +} + +function bucketsRoot() { + const shims = activeShimsDir(); + if (shims) return path.join(path.dirname(shims), 'buckets'); + const env = process.env || {}; + const roots = []; + if (env.SCOOP) roots.push(env.SCOOP); + if (env.USERPROFILE) roots.push(env.USERPROFILE + '\\scoop'); + roots.push('C:\\scoop', 'D:\\scoop'); + for (const r of roots) { + try { if (fs.existsSync(path.join(r, 'buckets'))) return path.join(r, 'buckets'); } catch (e) {} + } + return ''; +} + +/** 在所有本地 bucket 里找应用的清单文件(标准布局:buckets/<名>/bucket/.json 或根目录) */ +function findManifestFile(app) { + const root = bucketsRoot(); + if (!root) return ''; + let buckets = []; + try { + buckets = fs.readdirSync(root).filter((d) => { + try { return fs.statSync(path.join(root, d)).isDirectory(); } catch (e) { return false; } + }); + } catch (e) { return ''; } + for (const b of buckets) { + for (const sub of ['bucket', '']) { + const p = path.join(root, b, sub, app + '.json'); + try { if (fs.existsSync(p)) return p; } catch (e) {} + } + } + return ''; +} + +/** + * 就地临时镜像化 bucket 清单,返回 { path, changed, restore }。 + * + * ⚠ 为什么不能像 1.4.0 那样把改好的清单写到临时目录再 `scoop install `: + * 清单里的 post_install 脚本会引用 bucket 相对资源(实测 7zip: + * "$bucketsdir\$bucket\scripts\7-zip"),从临时目录安装时 scoop 不知道 bucket 归属, + * $bucket 为空 → 路径解析成 buckets\scripts\7-zip → 脚本报"找不到路径"。 + * 就地改写则 bucket 上下文原样保留,一切按正常安装路径走。 + * + * 安全性:原始内容先备份为 <原路径>.ztools-bak,restore() 用备份精确还原并删备份; + * install/update 的 finally 里必调 restore。若上次运行中途崩溃留下备份, + * 下一次调用会先检测并还原,bucket 的 git 工作区不会长期处于脏状态。 + */ +function patchManifestInPlace(app) { + const src = findManifestFile(app); + if (!src) return { path: '', changed: false, restore: null }; + const backup = src + '.ztools-bak'; + // 崩溃残留恢复:有备份说明上次没还原完,先还原再继续 + try { + if (fs.existsSync(backup)) fs.copyFileSync(backup, src); + } catch (e) {} + let original; + try { + original = fs.readFileSync(src, 'utf8'); + } catch (e) { return { path: src, changed: false, restore: null }; } + let obj; + try { obj = JSON.parse(original); } catch (e) { return { path: src, changed: false, restore: null }; } + const patched = JSON.stringify(deepMirrorize(obj), null, 2); + if (patched === original.trim() || patched === original) { + return { path: src, changed: false, restore: null }; // 清单里没有 GitHub 地址,不用动 + } + try { + fs.writeFileSync(backup, original, 'utf8'); + fs.writeFileSync(src, patched, 'utf8'); + } catch (e) { return { path: src, changed: false, restore: null }; } + const restore = () => { + try { + if (fs.existsSync(backup)) { + fs.copyFileSync(backup, src); + fs.unlinkSync(backup); + } + } catch (e) { /* 还原失败不能打断主流程 */ } + }; + return { path: src, changed: true, restore: restore }; +} + +// ---------- 写操作(全部流式日志) ---------- +/** + * bucket add 参数构造(纯函数,好测)。 + * ⚠ 1.3.2 的教训:chips 改传国内源时把 bucket 名字弄丢了,实际执行成 + * `scoop bucket add `,scoop 把 URL 当已知 bucket 别名去查, + * 报 "Unknown bucket ... Try specifying "(用户实测日志实锤)。 + * 名字和源必须都传:scoop bucket add + */ +function bucketAddArgs(name, source) { + const n = assertBucketName(name); + if (source && source !== n) return ['add', n, assertBucketSource(source)]; + return ['add', n]; +} +/** + * 安装:就地临时镜像化 bucket 清单(装完自动还原),再正常 `scoop install `。 + * 清单里没有 GitHub 地址时不动文件;应用不在任何本地 bucket 时退回普通安装。 + */ +const install = async (app, onLine) => { + const a = assertApp(app); + const ctx = patchManifestInPlace(a); + try { + if (ctx.changed) { + try { onLine('>> 清单下载地址已临时镜像化(GitHub → ' + MIRROR_PREFIX.trim() + '),装完自动还原'); } catch (e) {} + } + return await runScoopStream(['install', a], '安装 ' + a, onLine); + } finally { + if (ctx.restore) { + ctx.restore(); + try { onLine('>> 已还原原始清单'); } catch (e) {} + } + } +}; +const uninstall = (app, purge, onLine) => + runScoopStream(['uninstall', assertApp(app)].concat(purge ? ['-p'] : []), '卸载 ' + app, onLine); +/** 更新:先走 scoop update;失败(如 GitHub 下载被墙)时就地镜像化清单后强制重装。 + * 「已经是最新版」时 scoop update 正常返回,不会触发兜底。 */ +const updateApp = async (app, onLine) => { + const a = app === '*' ? '*' : assertApp(app); + try { + return await runScoopStream(['update'].concat(a === '*' ? ['*'] : [a]), '更新 ' + a, onLine); + } catch (e) { + if (a === '*') throw e; // 批量更新不做兜底,逐个失败的信息都在日志里 + const ctx = patchManifestInPlace(a); + if (!ctx.changed) throw e; + try { onLine('>> 直接更新失败,清单已临时镜像化,强制安装新版本'); } catch (e2) {} + try { + return await runScoopStream(['install', a, '-f'], '更新 ' + a + '(镜像清单)', onLine); + } finally { + ctx.restore(); + try { onLine('>> 已还原原始清单'); } catch (e3) {} + } + } +}; +const updateScoop = (onLine) => runScoopStream(['update'], '更新 Scoop 与 bucket', onLine); +// ---------- git 保障(bucket 管理的硬依赖) ---------- +// scoop 源码 add_bucket 第一行就是 Test-GitAvailable,没 git 直接 +// "Git is required for buckets"。而镜像安装的 scoop 是 zip 解包的,全新机器没有 +// git——加 bucket 前必须保障 git。装法走 npmmirror 的 git-for-windows 国内 CDN, +// 同样不依赖梯子。 +function gitAvailable() { + return new Promise((resolve) => { + const child = spawn('git.exe', ['--version'], { env: childEnv(), windowsHide: true }); + let settled = false; + const done = (ok) => { if (!settled) { settled = true; try { child.kill(); } catch (e) {} resolve(ok); } }; + const timer = setTimeout(() => done(false), 10000); + child.on('error', () => { clearTimeout(timer); done(false); }); + child.on('close', (code) => { clearTimeout(timer); done(code === 0); }); + }); +} + +/** git 静默安装脚本:npmmirror 列出版本 → 选最新稳定版 64 位 → 下载 → InnoSetup 静默装。 + * 用数组 join 而不是长串拼接:PS 引号转义太密,分行可读可测。 */ +function ensureGitScript() { + return [ + PS_PREFIX, + '[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; ', + "$list = irm 'https://registry.npmmirror.com/-/binary/git-for-windows/'; ", + "$ver = $list | ForEach-Object { $_.name } | ", + "Where-Object { $_ -match '^v(\\d+\\.\\d+\\.\\d+)\\.windows\\.\\d+/$' } | ", + "Sort-Object { [version]($_.TrimEnd('/').TrimStart('v') -replace '\\.windows\\.\\d+$', '') } -Descending | ", + 'Select-Object -First 1; ', + "if (!$ver) { throw 'npmmirror 上没有找到可用的 git 版本' }; ", + "$exeName = ($ver.TrimEnd('/') -replace '^v', 'Git-') -replace '\\.windows\\.\\d+$', ''; ", + '$url = "https://registry.npmmirror.com/-/binary/git-for-windows/" + $ver + $exeName + "-64-bit.exe"; ', + 'Write-Output ("下载 " + $url); ', + '$dst = Join-Path $env:TEMP ($exeName + "-64-bit.exe"); ', + 'irm $url -OutFile $dst; ', + 'Write-Output "开始静默安装(约 1 分钟,请稍候)…"; ', + "Start-Process -FilePath $dst -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART' -Wait; ", + 'Write-Output "安装程序执行完毕"; ' + ].join(''); +} + +/** 装完 git 后扫常见安装位置,把 git\cmd 注入子进程 PATH */ +function locateGitAndPatchPath() { + const candidates = [ + 'C:\\Program Files\\Git\\cmd', + 'C:\\Program Files (x86)\\Git\\cmd', + path.join(process.env.LOCALAPPDATA || '', 'Programs', 'Git', 'cmd') + ]; + for (const c of candidates) { + try { + if (c && fs.existsSync(path.join(c, 'git.exe'))) { + if (extraPathDirs.indexOf(c) < 0) extraPathDirs.push(c); + return c; + } + } catch (e) {} + } + return ''; +} + +/** 确保 git 可用;没有就自动装(npmmirror 镜像)。失败抛错由调用方兜底 */ +async function ensureGit(onLine) { + if (await gitAvailable()) return true; + try { onLine('>> 未检测到 git(bucket 管理的硬依赖),从 npmmirror 镜像自动安装…'); } catch (e) {} + locateGitAndPatchPath(); + if (await gitAvailable()) return true; + await streamPowerShell(ensureGitScript(), '安装 git', onLine); + const dir = locateGitAndPatchPath(); + if (!(await gitAvailable())) { + throw new Error('git 安装程序执行完了但仍检测不到 git(安装位置没找到 git.exe)。' + + '可以手动安装 git 后重试,或把 git\\cmd 目录加进系统 PATH'); + } + try { onLine('>> git 就绪(' + dir + ')'); } catch (e) {} + return true; +} + +const addBucket = async (name, source, onLine) => { + await ensureGit(onLine); + return runScoopStream(['bucket'].concat(bucketAddArgs(name, source)), '添加 bucket ' + name, onLine); +}; +const removeBucket = (name, onLine) => runScoopStream(['bucket', 'rm', assertBucketName(name)], '移除 bucket ' + name, onLine); +const clearCache = (onLine) => runScoopStream(['cache', 'rm', '*'], '清空下载缓存', onLine); +const cleanup = (onLine) => runScoopStream(['cleanup', '*'], '清理旧版本', onLine); +const holdApp = (app, hold, onLine) => + runScoopStream([hold ? 'hold' : 'unhold', assertApp(app)], (hold ? '锁定 ' : '解锁 ') + app, onLine); + +// ---------- 系统能力转发 ---------- +function openUrl(url) { + const z = ZT(); + if (!/^https?:\/\//.test(String(url || ''))) throw new Error('非法链接'); + if (z.shellOpenExternal) return z.shellOpenExternal(url); + return null; +} +function openPath(p) { + const z = ZT(); + if (z.shellOpenPath) return z.shellOpenPath(p); + return null; +} +function notify(body) { + const z = ZT(); + if (z.showNotification) return z.showNotification(String(body || '')); + if (z.showToast) return z.showToast(String(body || '')); + return null; +} +function copy(text) { + const z = ZT(); + if (z.copyText) return z.copyText(String(text || '')); + return null; +} +function theme() { + const z = ZT(); + try { + if (typeof z.isDarkColors === 'function') return { isDark: !!z.isDarkColors(), source: 'ztools' }; + } catch (e) {} + return { isDark: false, source: 'default' }; +} +function onThemeChange(cb) { + const z = ZT(); + if (typeof z.onThemeChange === 'function') { try { z.onThemeChange(cb); return true; } catch (e) { return false; } } + return false; +} + +// ---------- 暴露给页面 ---------- +const services = { + detect, installed, status, searchApps, infoApp, listBuckets, cacheInfo, prefixOf, + install, uninstall, updateApp, updateScoop, addBucket, removeBucket, clearCache, cleanup, holdApp, + installScoop, installScript, LOCAL_PROXY_CANDIDATES, MIRROR_PREFIX, portOpen, + bucketAddArgs, ensureGitScript, gitAvailable, ensureGit, + mirrorize, deepMirrorize, findManifestFile, patchManifestInPlace, bucketsRoot, + pickShimsDir, clearShimsDir, getSetupInfo, + stopOp, isBusy, busyLabel, + openUrl, openPath, notify, copy, theme, onThemeChange, + // 纯函数也暴露出去,页面做"已装标记/时间格式化"时直接复用 + parseScoopDate, extractJson, assertApp, looksLikeCommandNotFound, classifyDetect +}; + +if (typeof window !== 'undefined') { + // 与 weather 插件同款双保险:优先 contextBridge,挂不上就直接挂 window + var exposedBridge = false; + try { + var bridge = window.contextBridge; + if (bridge && typeof bridge.exposeInMainWorld === 'function') { + bridge.exposeInMainWorld('services', services); + exposedBridge = true; + } + } catch (e) { /* 走下方兜底 */ } + if (!exposedBridge) { + try { window.services = services; } catch (e) { /* ignore */ } + } + // ⚠ 不要写 mode:'none':宿主会把 'none' 当无界面插件处理,界面高度为 0 表现为打不开。 + // weather 插件踩过这个坑(详见 ztools-weather/preload.js 尾部注释),这里固定 'web'。 + try { + window.exports = { + 'scoop': { mode: 'web', args: { enter() { try { if (ZT().setExpendHeight) ZT().setExpendHeight(620); } catch (e) {} }, leave() {} } } + }; + } catch (e) { /* ignore */ } +} + +module.exports = services; diff --git a/plugins/ztools-scoop-manager/scripts/make_logo.py b/plugins/ztools-scoop-manager/scripts/make_logo.py new file mode 100644 index 000000000..bc7cec155 --- /dev/null +++ b/plugins/ztools-scoop-manager/scripts/make_logo.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# 生成插件 logo:圆角方块 + 提桶(bucket)+ 手柄,配色沿用 Scoop 官方蓝。 +from PIL import Image, ImageDraw + +S = 256 +img = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +d = ImageDraw.Draw(img) + +# 背景圆角方块 + 垂直渐变 +BLUE_TOP = (96, 165, 250) +BLUE_BOT = (37, 99, 235) +grad = Image.new("RGBA", (S, S), (0, 0, 0, 0)) +gd = ImageDraw.Draw(grad) +for y in range(S): + t = y / S + col = tuple(int(BLUE_TOP[i] + (BLUE_BOT[i] - BLUE_TOP[i]) * t) for i in range(3)) + gd.line([(0, y), (S, y)], fill=col + (255,)) +mask = Image.new("L", (S, S), 0) +ImageDraw.Draw(mask).rounded_rectangle([16, 16, 240, 240], radius=52, fill=255) +img.paste(grad, (0, 0), mask) +d = ImageDraw.Draw(img) +d.rounded_rectangle([16, 16, 240, 240], radius=52, outline=(219, 234, 254), width=4) + +# 提桶:梯形桶身 + 桶沿 + 弧形手柄 +WHITE = (248, 250, 252) +d.polygon([(76, 96), (180, 96), (166, 196), (90, 196)], fill=WHITE) +d.rounded_rectangle([68, 84, 188, 102], radius=9, fill=WHITE) +d.arc([86, 44, 170, 118], start=180, end=360, fill=WHITE, width=12) + +# 桶里的两个"包":一深一浅两个圆角小块 +d.rounded_rectangle([94, 118, 124, 146], radius=7, fill=(37, 99, 235)) +d.rounded_rectangle([130, 118, 160, 146], radius=7, fill=(147, 197, 253)) + +# 右上角更新箭头(⬆),表示"更新"这个核心动作 +GOLD = (253, 224, 71) +d.line([(196, 66), (196, 34)], fill=GOLD, width=11) +d.polygon([(184, 44), (208, 44), (196, 26)], fill=GOLD) + +img.save("logo.png") +print("saved logo.png", img.size) diff --git a/plugins/ztools-scoop-manager/style.css b/plugins/ztools-scoop-manager/style.css new file mode 100644 index 000000000..00452cfaa --- /dev/null +++ b/plugins/ztools-scoop-manager/style.css @@ -0,0 +1,191 @@ +/* Scoop 包管理器 · 样式。跟随宿主深浅色:app.js 往 body 上挂 .dark */ +:root { + --bg: #f8fafc; + --bg-soft: #f1f5f9; + --panel: #ffffff; + --line: #e2e8f0; + --text: #0f172a; + --text-dim: #64748b; + --primary: #2563eb; + --primary-soft: #dbeafe; + --danger: #dc2626; + --danger-soft: #fee2e2; + --warn: #b45309; + --warn-soft: #fef3c7; + --ok: #16a34a; + --ok-soft: #dcfce7; +} +body.dark { + --bg: #0b1220; + --bg-soft: #101a2e; + --panel: #131f36; + --line: #24324d; + --text: #e5eaf3; + --text-dim: #8ea0bd; + --primary: #3b82f6; + --primary-soft: #1e3a8a55; + --danger: #f87171; + --danger-soft: #7f1d1d55; + --warn: #fbbf24; + --warn-soft: #78350f55; + --ok: #4ade80; + --ok-soft: #14532d55; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + display: flex; flex-direction: column; + background: var(--bg); color: var(--text); + font: 13px/1.5 "Segoe UI", "Microsoft YaHei", sans-serif; + overflow: hidden; +} +.spacer { flex: 1; } +button { font: inherit; cursor: pointer; } +input { + font: inherit; color: var(--text); + background: var(--panel); border: 1px solid var(--line); + border-radius: 8px; padding: 6px 10px; outline: none; +} +input:focus { border-color: var(--primary); } + +/* ---------- 顶栏 ---------- */ +#topbar { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); +} +.logo { width: 34px; height: 34px; border-radius: 9px; } +.title { font-size: 15px; font-weight: 600; } +.chip { + font-size: 11px; color: var(--text-dim); + border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; +} +.chip.ok { color: var(--ok); border-color: var(--ok); } +.chip.bad { color: var(--danger); border-color: var(--danger); } + +.btn { + border: 1px solid var(--line); border-radius: 8px; + padding: 6px 12px; background: var(--panel); color: var(--text); +} +.btn:hover { border-color: var(--primary); color: var(--primary); } +.btn.small { padding: 3px 9px; font-size: 12px; } +.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; } +.btn.primary:hover { opacity: .9; color: #fff; } +.btn.danger { color: var(--danger); border-color: var(--danger); } +.btn.danger:hover { background: var(--danger-soft); } +.btn.ghost { background: transparent; } +.btn:disabled { opacity: .45; cursor: not-allowed; } + +/* ---------- 页签 ---------- */ +#tabs { + display: flex; gap: 2px; padding: 8px 14px 0; + background: var(--panel); border-bottom: 1px solid var(--line); +} +.tab { + border: none; background: transparent; color: var(--text-dim); + padding: 7px 14px; border-radius: 8px 8px 0 0; + border-bottom: 2px solid transparent; +} +.tab:hover { color: var(--text); } +.tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; } +.cnt { + font-size: 11px; background: var(--primary-soft); color: var(--primary); + border-radius: 999px; padding: 0 6px; margin-left: 2px; +} +.cnt:empty { display: none; } + +/* ---------- 主体 ---------- */ +#main { flex: 1; overflow: hidden; display: flex; } +.pane { display: none; flex: 1; flex-direction: column; overflow: hidden; padding: 10px 14px; } +.pane.active { display: flex; } +.toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } +.toolbar input { flex: 0 1 320px; } +#filter-installed, #search-input, #bucket-input { flex: 1 1 auto; max-width: 420px; } +.meta { color: var(--text-dim); font-size: 12px; } + +.table-wrap { flex: 1; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); } +.tbl { width: 100%; border-collapse: collapse; } +.tbl th { + position: sticky; top: 0; z-index: 1; + text-align: left; font-weight: 600; font-size: 12px; color: var(--text-dim); + background: var(--bg-soft); padding: 7px 10px; border-bottom: 1px solid var(--line); + white-space: nowrap; +} +.tbl td { padding: 6px 10px; border-bottom: 1px solid var(--line); vertical-align: middle; } +.tbl tbody tr:hover { background: var(--bg-soft); } +.tbl .name { font-weight: 600; } +.tbl .dim { color: var(--text-dim); font-size: 12px; } +.tbl td.dim { white-space: nowrap; } +/* 名称列的副行:bucket 来源 + 状态标签,替代表格里的独立列省宽度 */ +.tbl .name-cell .sub { font-size: 11px; color: var(--text-dim); margin-top: 1px; } +.tbl .name-cell .sub .tag { margin-left: 4px; } +/* 长链接截断(Bucket 来源),悬停 title 看全貌 */ +.truncate { + display: inline-block; max-width: 200px; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; +} +.th-ops, .td-ops { text-align: right; white-space: nowrap; } +.td-ops .btn { margin-left: 4px; padding-left: 8px; padding-right: 8px; } + +.tag { + font-size: 11px; border-radius: 999px; padding: 1px 8px; white-space: nowrap; +} +.tag.held { color: var(--warn); background: var(--warn-soft); } +.tag.bad { color: var(--danger); background: var(--danger-soft); } +.tag.ok { color: var(--ok); background: var(--ok-soft); } +.tag.info { color: var(--text-dim); background: var(--bg-soft); } + +.empty { padding: 28px; text-align: center; color: var(--text-dim); } +.group-title { font-size: 12px; color: var(--warn); margin: 10px 2px 6px; } +.warnings { + margin-top: 8px; padding: 6px 10px; border-radius: 8px; + background: var(--warn-soft); color: var(--warn); font-size: 12px; + white-space: pre-wrap; word-break: break-all; max-height: 90px; overflow: auto; +} + +.known-buckets { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.known-buckets .btn { font-size: 12px; padding: 2px 10px; border-radius: 999px; } + +/* ---------- 配置页(无 scoop 环境时的自举) ---------- */ +body.setup-mode #tabs, body.setup-mode #main { display: none; } +body.setup-mode #topbar .btn { display: none; } +#setup { flex: 1; overflow: auto; padding: 20px; } +.setup-card { max-width: 480px; margin: 20px auto 0; text-align: center; } +.setup-card img { width: 60px; height: 60px; border-radius: 14px; } +.setup-card h2 { font-size: 17px; margin: 10px 0 6px; } +.setup-card > p { color: var(--text-dim); font-size: 13px; } +.setup-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin: 16px 0 4px; } +.setup-hint { + font-size: 12px; text-align: left; color: var(--text-dim); + background: var(--panel); border: 1px solid var(--line); border-radius: 8px; + padding: 8px 10px; margin-top: 8px; word-break: break-all; +} +.setup-hint code { color: var(--primary); } + +/* ---------- 日志抽屉 ---------- */ +.drawer { + border-top: 1px solid var(--line); background: var(--panel); + display: flex; flex-direction: column; height: 190px; +} +/* hidden 属性会被上面的 display:flex 干掉,必须显式压回来 */ +.drawer[hidden] { display: none; } +.drawer-head { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--line); font-weight: 600; } +.drawer-log { + flex: 1; overflow: auto; padding: 8px 12px; + font: 12px/1.55 Consolas, monospace; white-space: pre-wrap; word-break: break-all; + color: var(--text-dim); +} +.drawer-log .err { color: var(--danger); } + +/* ---------- 状态栏 ---------- */ +#statusbar { + display: flex; align-items: center; gap: 8px; + padding: 5px 14px; border-top: 1px solid var(--line); + background: var(--panel); color: var(--text-dim); font-size: 12px; +} +#statusbar.busy #status-text { color: var(--primary); } + +/* 滚动条 */ +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-thumb { background: var(--line); border-radius: 5px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-dim); } diff --git a/plugins/ztools-scoop-manager/tests/preview.html b/plugins/ztools-scoop-manager/tests/preview.html new file mode 100644 index 000000000..c3c79bd33 --- /dev/null +++ b/plugins/ztools-scoop-manager/tests/preview.html @@ -0,0 +1,100 @@ + + + + + + Scoop 包管理器 · 预览 + + + + + + + diff --git a/plugins/ztools-scoop-manager/tests/run_tests.js b/plugins/ztools-scoop-manager/tests/run_tests.js new file mode 100644 index 000000000..f0d6d2c93 --- /dev/null +++ b/plugins/ztools-scoop-manager/tests/run_tests.js @@ -0,0 +1,329 @@ +/** + * Scoop 包管理器 · 测试 + * node tests/run_tests.js —— 纯函数测试(vm 沙箱加载 preload,不需要 scoop) + * node tests/run_tests.js --live —— 追加真实命令冒烟(调用本机 scoop,联网可能较慢) + */ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const assert = require('assert'); + +// ---------- vm 沙箱加载 preload.js ---------- +// 与 ztools-weather 的测试同款做法:preload 依赖 window/document/require/process, +// 沙箱里给最小桩。window.ztools 全部 no-op,纯函数测试不碰系统能力。 +function loadPreload() { + const code = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8'); + const ztoolsStub = { + setExpendHeight() {}, showMainWindow() {}, showNotification() {}, showToast() {}, + copyText() {}, shellOpenExternal() {}, shellOpenPath() {}, + isDarkColors() { return false; } + }; + const sandbox = { + window: { ztools: ztoolsStub }, + console, + process, + require, + Buffer, + setTimeout, clearTimeout, setInterval, clearInterval, + __dirname: path.join(__dirname, '..'), + module: { exports: {} } + }; + vm.createContext(sandbox); + vm.runInContext(code, sandbox, { filename: 'preload.js' }); + return sandbox.module.exports; +} + +const S = loadPreload(); +let passed = 0, failed = 0; +function t(name, fn) { + try { fn(); passed++; console.log(' ✓ ' + name); } + catch (e) { failed++; console.log(' ✗ ' + name + '\n ' + (e && e.message)); } +} + +// ---------- extractJson:从混杂输出里抽 JSON ---------- +console.log('extractJson'); +t('剥掉表头与告警行,取出数组', () => { + const out = 'Installed apps:\nWARN Scoop bucket(s) out of date.\n[{"Name":"7zip","Version":"26.03"}]'; + const r = S.extractJson(out); + assert.strictEqual(r.data[0].Name, '7zip'); + // "Installed apps:" 是表头不该进 warnings,WARN 才该进 + // vm 沙箱里的数组来自另一个 realm,原型不等,deepStrictEqual 会误报;用字符串比 + assert.strictEqual(JSON.stringify(r.warnings), JSON.stringify(['WARN Scoop bucket(s) out of date.'])); +}); +t('对象形态也能抽', () => { + const r = S.extractJson('fatal: unable to access ...\n{"Name":"git"}'); + assert.strictEqual(r.data.Name, 'git'); + assert.strictEqual(r.warnings.length, 1); +}); +t('没有 JSON 时报错且错误可读', () => { + assert.throws(() => S.extractJson('scoop : 无法将"scoop"项识别为 cmdlet'), /JSON/); +}); +t('前缀行含方括号(非 JSON 开头)不误判', () => { + const out = '下载 [====================] 100%\n[{"Name":"a"}]'; + const r = S.extractJson(out); + assert.strictEqual(r.data[0].Name, 'a'); + // "下载 [====..." 是进度行,会留在 warnings 里(页面只展示,不影响数据) +}); + +// ---------- parseScoopDate:三种日期形态 ---------- +console.log('parseScoopDate'); +t('/Date(ms)/ 字符串', () => { + assert.strictEqual(S.parseScoopDate('/Date(1788914656051)/'), 1788914656051); +}); +t('对象形态(bucket list 的 Updated)', () => { + assert.strictEqual(S.parseScoopDate({ value: '/Date(1790743030000)/' }), 1790743030000); +}); +t('普通日期串与空值', () => { + assert.ok(S.parseScoopDate('2026-09-09 08:44:16') > 0); + assert.strictEqual(S.parseScoopDate(''), 0); + assert.strictEqual(S.parseScoopDate(null), 0); +}); + +// ---------- assertApp:防 PS 注入的唯一防线 ---------- +console.log('assertApp'); +t('合法名:常规 / 带 @版本 / 带下划线连字符', () => { + assert.strictEqual(S.assertApp('7zip'), '7zip'); + assert.strictEqual(S.assertApp('7zip@16.04'), '7zip@16.04'); + assert.strictEqual(S.assertApp('7zip-beta_DoveBoy'), '7zip-beta_DoveBoy'); +}); +t('拒绝命令注入', () => { + assert.throws(() => S.assertApp('a; Remove-Item C:\\'), /非法/); + assert.throws(() => S.assertApp('a | Format-Volume'), /非法/); + assert.throws(() => S.assertApp('$(calc)'), /非法/); + assert.throws(() => S.assertApp('a b'), /非法/); // 空格(多参数)不允许 + assert.throws(() => S.assertApp('-Expression x'), /非法/); // 连字符开头会被当 scoop 参数 + assert.throws(() => S.assertApp(''), /非法/); +}); + +// ---------- bucket 来源校验 ---------- +// assertBucketSource 没直接导出,经 addBucket 走会真执行命令,这里用等价正则在本文件复刻校验 +console.log('bucket source 校验规则'); +t('URL / 官方短名 / 本机路径都合法(正则复刻)', () => { + const URL_RE = /^https?:\/\/\S{1,300}$/; + const NAME_RE = /^[\w.-]{1,64}$/; + const PATH_RE = /^[A-Za-z]:[\\/]\S{0,299}$/; + assert.ok(URL_RE.test('https://github.com/ScoopInstaller/Extras')); + assert.ok(NAME_RE.test('extras')); + assert.ok(PATH_RE.test('D:\\buckets\\my')); + assert.ok(!URL_RE.test('file://x') && !NAME_RE.test('a b') && !PATH_RE.test('relative/path')); +}); + +// ---------- 安装脚本(TLS/镜像/代理/管理员四件套) ---------- +console.log('installScript'); +t('通用骨架:TLS1.2 + try/catch 执行策略 + 管理员分支', () => { + for (const mirror of [true, false]) { + const s = S.installScript('', mirror); + assert.ok(/SecurityProtocol.*Tls12/.test(s), '应强制 TLS1.2'); + assert.ok(/try \{ Set-ExecutionPolicy/.test(s), '执行策略应包在 try/catch 里'); + assert.ok(/-RunAsAdmin/.test(s), '应含管理员分支'); + } +}); +t('镜像版:下载地址打镜像前缀补丁 + 安装器本体有镜像兜底', () => { + const s = S.installScript('', true); + assert.ok(s.indexOf("Replace(\"'https://github.com/\"") >= 0, '应包含 GitHub URL 前缀补丁'); + assert.ok(s.indexOf('https://scoop.201704.xyz/https://raw.githubusercontent.com') >= 0, '安装器本体应有镜像兜底下载'); + assert.ok(!/-Proxy /.test(s), '直连不应带 -Proxy'); +}); +t('官方版:不打补丁、不引镜像', () => { + const s = S.installScript('', false); + assert.ok(s.indexOf('scoop.201704.xyz') < 0, '官方版不应出现镜像地址'); + assert.ok(s.indexOf('.Replace(') < 0, '官方版不应打补丁'); +}); +t('代理版把 -Proxy 传给每个下载点', () => { + // 官方版:irm 1 处 + installer 两个分支 = 3 + assert.strictEqual((S.installScript('http://127.0.0.1:7890', false).match(/-Proxy 'http:\/\/127\.0\.0\.1:7890'/g) || []).length, 3); + // 镜像版:irm 的 try/catch 共 2 处 + installer 两个分支 = 4 + assert.strictEqual((S.installScript('http://127.0.0.1:7890', true).match(/-Proxy 'http:\/\/127\.0\.0\.1:7890'/g) || []).length, 4); +}); + +// ---------- bucket add 参数(1.3.2 丢名字 bug 的回归测试) ---------- +console.log('bucketAddArgs'); +t('名字 + 源都要传:scoop bucket add ', () => { + const url = 'https://scoop.201704.xyz/https://github.com/ScoopInstaller/Nonportable'; + const args = S.bucketAddArgs('nonportable', url); + assert.deepStrictEqual(JSON.parse(JSON.stringify(args)), ['add', 'nonportable', url]); + assert.ok(args.indexOf('(国内直连源)') < 0, '展示用文案绝不能混进参数'); +}); +t('只给名字(已知名 bucket):不带第二个参数', () => { + assert.strictEqual(JSON.stringify(S.bucketAddArgs('extras', '')), JSON.stringify(['add', 'extras'])); + assert.strictEqual(JSON.stringify(S.bucketAddArgs('main', 'main')), JSON.stringify(['add', 'main'])); +}); +t('非法名字仍然拒绝', () => { + assert.throws(() => S.bucketAddArgs('a; calc', ''), /非法/); +}); + +// ---------- 清单镜像化(装软件层面的网络问题) ---------- +console.log('mirrorize / deepMirrorize'); +t('GitHub 各类域名打前缀,非 GitHub 一律不动', () => { + const M = 'https://scoop.201704.xyz/'; + assert.strictEqual(S.mirrorize('https://github.com/a/b/releases/download/v1/x.zip'), M + 'https://github.com/a/b/releases/download/v1/x.zip'); + assert.strictEqual(S.mirrorize('https://raw.githubusercontent.com/a/b/master/x'), M + 'https://raw.githubusercontent.com/a/b/master/x'); + assert.strictEqual(S.mirrorize('https://objects.githubusercontent.com/x'), M + 'https://objects.githubusercontent.com/x'); + assert.strictEqual(S.mirrorize('https://www.7-zip.org/a/x.zip'), 'https://www.7-zip.org/a/x.zip'); + assert.strictEqual(S.mirrorize('https://github.cn-mirror.not/a'), 'https://github.cn-mirror.not/a'); // 前缀匹配而非包含 +}); +t('深遍历:数组、嵌套对象、hash 的 #sha256 后缀一起改', () => { + const m = S.deepMirrorize({ + url: 'https://github.com/a/b/download/x.zip', + architecture: { '64bit': { url: ['https://github.com/a/b/d/x.zip'], hash: ['https://github.com/a/b/d/x.zip#sha256'] } }, + license: 'MIT', + note: 7 + }); + assert.strictEqual(m.url, 'https://scoop.201704.xyz/https://github.com/a/b/download/x.zip'); + assert.strictEqual(m.architecture['64bit'].hash[0], 'https://scoop.201704.xyz/https://github.com/a/b/d/x.zip#sha256'); + assert.strictEqual(m.license, 'MIT'); + assert.strictEqual(m.note, 7); +}); + +// ---------- 命令不存在识别(1.4.1 卸载 scoop 后误判在线的回归测试) ---------- +console.log('looksLikeCommandNotFound'); +t('中文 PowerShell 的 CommandNotFoundException 体裁', () => { + const zh = "scoop : 无法将'scoop'项识别为 cmdlet、函数、脚本文件或可运行程序的名称。"; + assert.strictEqual(S.looksLikeCommandNotFound(zh), true); +}); +t('英文 PowerShell 体裁与退出码文本', () => { + assert.strictEqual(S.looksLikeCommandNotFound("The term 'scoop' is not recognized as the name of a cmdlet"), true); + assert.strictEqual(S.looksLikeCommandNotFound("'scoop' 不是内部或外部命令,也不是可运行的程序"), true); +}); +t('正常输出不误判', () => { + assert.strictEqual(S.looksLikeCommandNotFound('Current Scoop version:\nv0.6.0 - Released at 2026-09-30'), false); + assert.strictEqual(S.looksLikeCommandNotFound(''), false); +}); + +console.log('classifyDetect'); +t('卸载场景:错误在 stderr(raw 里),判未安装', () => { + const zhErr = "scoop : 无法将'scoop'项识别为 cmdlet、函数、脚本文件或可运行程序的名称。"; + const r = { data: [], warnings: [zhErr], empty: true, code: 1, raw: zhErr }; + assert.strictEqual(S.classifyDetect(r), 'not-found'); +}); +t('新装无 git 场景:版本行在宿主流,判在线(哪怕退出码非 0)', () => { + const r = { + data: [], + warnings: ['Current Scoop version:', 'v0.6.0 - Released at 2026-09-30', "fatal: unable to access 'https://github.com...'"], + empty: true, code: 1, + raw: 'Current Scoop version:\nv0.6.0 - Released at 2026-09-30' + }; + assert.strictEqual(S.classifyDetect(r), 'online'); +}); +t('空结果 + 退出码非 0 + 无版本号:判未安装(覆盖未知体裁)', () => { + const r = { data: [], warnings: ['Some weird failure'], empty: true, code: 1, raw: 'Some weird failure' }; + assert.strictEqual(S.classifyDetect(r), 'not-found'); +}); +t('正常 JSON 数据:判在线', () => { + const r = { data: [{ Name: '7zip' }], warnings: [], code: 0 }; + assert.strictEqual(S.classifyDetect(r), 'online'); +}); + +// ---------- 实机冒烟(--live) ---------- +if (process.argv.includes('--live')) { + console.log('\n-- live(真实调用本机 scoop)--'); + (async () => { + const d = await S.detect(); + console.log(' detect: v' + d.version + (d.warnings.length ? ' warnings=' + d.warnings.length : '')); + assert.ok(d.ok && d.version !== '未知版本', 'detect 应拿到版本号'); + + const inst = await S.installed(); + console.log(' installed: ' + inst.apps.length + ' 个应用,第一个=' + + (inst.apps[0] ? inst.apps[0].name + '@' + inst.apps[0].version : '无')); + assert.ok(inst.apps.length > 0, '已安装列表不应为空'); + const withInfo = inst.apps.filter((a) => a.info); + console.log(' 带 Info 标记的应用: ' + withInfo.map((a) => a.name + '(' + a.info + ')').join(', ')); + + const bk = await S.listBuckets(); + console.log(' buckets: ' + bk.buckets.map((b) => b.name + '(' + b.manifests + ')').join(', ')); + + const cache = await S.cacheInfo(); + console.log(' cache: ' + cache.total.files + ' 文件 ' + cache.total.sizeText + + ',按应用聚合 ' + cache.apps.length + ' 个'); + + const sr = await S.searchApps('fzf'); + console.log(' search fzf: ' + sr.results.length + ' 条,首条=' + + (sr.results[0] ? sr.results[0].name + '@' + sr.results[0].version : '无')); + + // status 走网络,容易因代理抖动失败;失败不判死,打印即可 + try { + const st = await S.status(); + console.log(' status: 可更新 ' + st.outdated.length + ',异常 ' + st.broken.length + + (st.warnings.length ? ',warnings=' + st.warnings.length : '')); + } catch (e) { console.log(' status 跳过:' + String(e.message).split('\n')[0]); } + + // prefix + info(挑一个必然存在的) + const p = await S.prefixOf(inst.apps[0].name); + console.log(' prefix ' + inst.apps[0].name + ': ' + p); + assert.ok(/[\\/]apps[\\/]/.test(p), 'prefix 应返回 apps 目录下的路径'); + const info = await S.infoApp(inst.apps[0].name); + console.log(' info: Website=' + (info.info.Website || '--')); + + // 本机代理探测(端口活着就算过,探测结果打印出来供人工核对) + const probes = []; + for (const proxy of S.LOCAL_PROXY_CANDIDATES) { + if (await S.portOpen(proxy)) probes.push(proxy); + } + console.log(' portOpen: 在监听的代理端口 = ' + (probes.length ? probes.join(', ') : '(无)')); + + // 安装脚本的 PS 语法必须能解析(不执行,只过编译器)。 + // 走临时文件而不是 stdin:-Command 场景下 $input 对 stdin 的枚举不可靠(实测踩坑)。 + const fsx = require('fs'); + const pathx = require('path'); + const checkFile = pathx.join(__dirname, '_parse_check.ps1'); + const { spawn } = require('child_process'); + for (const [mirror, proxy] of [[true, ''], [false, ''], [false, 'http://127.0.0.1:7890']]) { + fsx.writeFileSync(checkFile, S.installScript(proxy, mirror)); + const out = await new Promise((resolve) => { + const child = spawn('powershell.exe', ['-NoProfile', '-Command', + // PS 单引号串里反斜杠就是字面量,路径不需要转义 + "$t = [IO.File]::ReadAllText('" + checkFile + "'); " + + "try { [void][scriptblock]::Create($t); 'PARSE_OK' } catch { 'PARSE_FAIL: ' + $_.Exception.Message }"]); + let acc = ''; + child.stdout.on('data', (d) => { acc += d; }); + child.stderr.on('data', (d) => { acc += d; }); + child.on('close', () => resolve(acc)); + child.on('error', (e) => resolve(String(e))); + }); + fsx.unlinkSync(checkFile); + if (out.indexOf('PARSE_OK') < 0) { + throw new Error('PS 解析失败(镜像=' + mirror + ',代理=' + (proxy || '直连') + '):' + out.trim().slice(0, 200)); + } + } + // git 自动安装脚本同样要过 PS 编译器 + fsx.writeFileSync(checkFile, S.ensureGitScript()); + const gitOut = await new Promise((resolve) => { + const child = spawn('powershell.exe', ['-NoProfile', '-Command', + "$t = [IO.File]::ReadAllText('" + checkFile + "'); " + + "try { [void][scriptblock]::Create($t); 'PARSE_OK' } catch { 'PARSE_FAIL: ' + $_.Exception.Message }"]); + let acc = ''; + child.stdout.on('data', (d) => { acc += d; }); + child.stderr.on('data', (d) => { acc += d; }); + child.on('close', () => resolve(acc)); + child.on('error', (e) => resolve(String(e))); + }); + fsx.unlinkSync(checkFile); + if (gitOut.indexOf('PARSE_OK') < 0) { + throw new Error('ensureGitScript PS 解析失败:' + gitOut.trim().slice(0, 200)); + } + console.log(' installScript/ensureGitScript: PS 语法解析通过'); + + // 清单镜像化实链路:就地改写 → 校验 → 还原 → 校验字节级一致 + const fsx2 = require('fs'); + const mfPath = S.findManifestFile('aria2'); + assert.ok(mfPath, '本机 main bucket 里应能找到 aria2 清单'); + console.log(' findManifestFile(aria2): ' + mfPath); + const originalBytes = fsx2.readFileSync(mfPath, 'utf8'); + const ctx = S.patchManifestInPlace('aria2'); + assert.ok(ctx.changed, 'aria2 清单含 GitHub 地址,应发生改写'); + const patchedText = fsx2.readFileSync(mfPath, 'utf8'); + assert.ok(patchedText.indexOf('scoop.201704.xyz/https://github.com') >= 0, 'GitHub 地址应被打上镜像前缀'); + assert.strictEqual(patchedText.indexOf('"https://github.com'), -1, '不应残留未镜像化的裸 GitHub 地址'); + ctx.restore(); + const restoredBytes = fsx2.readFileSync(mfPath, 'utf8'); + assert.strictEqual(restoredBytes, originalBytes, '还原后必须与原文件字节一致'); + assert.ok(!fsx2.existsSync(mfPath + '.ztools-bak'), '备份文件应已清理'); + console.log(' patchManifestInPlace(aria2): 改写→校验→还原 字节一致 ✅'); + + console.log('\n全部通过 ✅'); + })().catch((e) => { console.error('\nlive 冒烟失败:', e); process.exit(1); }); +} else { + console.log('\n纯函数测试 ' + (failed ? '有失败 ❌' : '全部通过 ✅') + + '(加 --live 可顺带跑真实 scoop 冒烟)'); + process.exit(failed ? 1 : 0); +}