diff --git a/plugins/system-diagnostic-report/CHANGELOG.md b/plugins/system-diagnostic-report/CHANGELOG.md new file mode 100644 index 000000000..e8a9223e7 --- /dev/null +++ b/plugins/system-diagnostic-report/CHANGELOG.md @@ -0,0 +1,11 @@ +# 更新日志 + +## 0.1.1 - 2026-09-01 + +- 适配 ZTools 3.2:导出后的 Markdown 和 JSON 可直接拖到外部应用。 +- 兼容 ZTools 2.4–3.1:保留采集、复制、保存和打开文件夹流程。 +- 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时仅显示升级提示,不启动采集或后台任务。 + +## 0.1.0 + +- 首次发布本地系统与硬件信息采集、脱敏展示及诊断报告导出能力。 diff --git a/plugins/system-diagnostic-report/README.md b/plugins/system-diagnostic-report/README.md index 61ada2ff8..1a17fc991 100644 --- a/plugins/system-diagnostic-report/README.md +++ b/plugins/system-diagnostic-report/README.md @@ -1,5 +1,11 @@ # 系统诊断报告 +## ZTools 兼容性 + +- ZTools 3.2:导出后的 Markdown/JSON 可使用 `startDrag` 拖到外部应用;ESC 隐藏期间采集会在 Preload 安全完成,重新进入不会重复启动采集。 +- ZTools 2.4–3.1:保留采集、复制、保存和文件夹工作流;缺少 `startDrag` 时不会显示拖出入口。 +- 低于 2.4:插件只显示升级提示,不启动采集或后台任务。 + 一个面向 ZTools 的本地系统信息查看与诊断报告生成工具。它把排障时常用的软硬件信息集中到一处,并提供适合复制或保存的报告,减少在不同系统设置与命令行工具之间来回查找的成本。 ![系统诊断报告界面](screenshots/main.png) diff --git a/plugins/system-diagnostic-report/package.json b/plugins/system-diagnostic-report/package.json index 3589541a7..a75e90087 100644 --- a/plugins/system-diagnostic-report/package.json +++ b/plugins/system-diagnostic-report/package.json @@ -1,6 +1,6 @@ { "name": "system-diagnostic-report", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "description": "本地生成软硬件系统信息与脱敏诊断报告的 ZTools 插件。", diff --git a/plugins/system-diagnostic-report/public/plugin.json b/plugins/system-diagnostic-report/public/plugin.json index 59728feeb..ccc5d6b77 100644 --- a/plugins/system-diagnostic-report/public/plugin.json +++ b/plugins/system-diagnostic-report/public/plugin.json @@ -3,7 +3,7 @@ "name": "system-diagnostic-report", "title": "系统诊断报告", "description": "在本地采集系统与硬件摘要,生成便于排障和分享的诊断报告。", - "version": "0.1.0", + "version": "0.1.1", "main": "index.html", "preload": "preload/services.js", "logo": "logo.svg", diff --git a/plugins/system-diagnostic-report/public/preload/host-compatibility.cjs b/plugins/system-diagnostic-report/public/preload/host-compatibility.cjs new file mode 100644 index 000000000..9f0b3069d --- /dev/null +++ b/plugins/system-diagnostic-report/public/preload/host-compatibility.cjs @@ -0,0 +1,31 @@ +'use strict' + +const MINIMUM_VERSION = Object.freeze([2, 4, 0]) + +function parseVersion(value) { + if (typeof value !== 'string') return null + const match = value.trim().match(/^[vV]?(\d+)\.(\d+)(?:\.(\d+))?(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/) + if (!match) return null + const parts = [Number(match[1]), Number(match[2]), Number(match[3] || 0)] + return parts.every(Number.isSafeInteger) ? parts : null +} + +function getHostCompatibility(hostWindow) { + const api = hostWindow?.ztools + if (!api) return Object.freeze({ supported: true, detected: false, version: null }) + if (typeof api.getAppVersion !== 'function') { + return Object.freeze({ supported: false, detected: true, version: null }) + } + let version + try { version = api.getAppVersion() } catch { + return Object.freeze({ supported: false, detected: true, version: null }) + } + const parsed = parseVersion(version) + if (!parsed) return Object.freeze({ supported: false, detected: true, version: null }) + const difference = parsed.findIndex((part, index) => part !== MINIMUM_VERSION[index]) + const minimumPrerelease = difference === -1 && /^[vV]?\d+\.\d+(?:\.\d+)?-/.test(String(version).trim()) + const supported = !minimumPrerelease && (difference === -1 || parsed[difference] > MINIMUM_VERSION[difference]) + return Object.freeze({ supported, detected: true, version: `${parsed[0]}.${parsed[1]}.${parsed[2]}` }) +} + +module.exports = Object.freeze({ MINIMUM_VERSION, getHostCompatibility, parseVersion }) diff --git a/plugins/system-diagnostic-report/public/preload/package.json b/plugins/system-diagnostic-report/public/preload/package.json index 696796c7c..de2f10666 100644 --- a/plugins/system-diagnostic-report/public/preload/package.json +++ b/plugins/system-diagnostic-report/public/preload/package.json @@ -1,6 +1,6 @@ { "name": "system-diagnostic-report-preload", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "commonjs", "main": "services.js", diff --git a/plugins/system-diagnostic-report/public/preload/services.js b/plugins/system-diagnostic-report/public/preload/services.js index 4c4502c72..64bdee043 100644 --- a/plugins/system-diagnostic-report/public/preload/services.js +++ b/plugins/system-diagnostic-report/public/preload/services.js @@ -2,12 +2,21 @@ const fs = require('node:fs/promises') const path = require('node:path') -const nodeOs = require('node:os') -const systeminformation = require('systeminformation') -const { collectSystemReport } = require('./collectors/core.cjs') +const { getHostCompatibility } = require('./host-compatibility.cjs') + +const compatibility = getHostCompatibility(typeof window === 'undefined' ? undefined : window) +// systeminformation and the collector graph may spawn probes. Do not even +// require them until a real host has positively identified itself as 2.4+. +const nodeOs = compatibility.supported ? require('node:os') : null +const systeminformation = compatibility.supported ? require('systeminformation') : null +const collectSystemReport = compatibility.supported + ? require('./collectors/core.cjs').collectSystemReport + : null const MAX_REPORT_BYTES = 20 * 1024 * 1024 +const DRAG_GRANT_TTL_MS = 5 * 60 * 1000 const collectionFlights = new Map() +const dragGrants = new Map() function hostApi() { return window && window.ztools ? window.ztools : null @@ -35,6 +44,9 @@ function getDisplays() { } function collect(options = {}) { + if (!compatibility.supported || !collectSystemReport) { + return Promise.reject(new Error('ZTools 2.4.0 or newer is required')) + } const privacy = options && options.privacy === 'fingerprint-minimal' ? 'fingerprint-minimal' : 'safe' @@ -59,6 +71,7 @@ function collect(options = {}) { } async function copyText(text) { + if (!compatibility.supported) return false if (typeof text !== 'string') return false const api = hostApi() if (!api || typeof api.copyText !== 'function') return false @@ -79,6 +92,7 @@ function safeDefaultName(value, format) { } async function saveReport(options = {}) { + if (!compatibility.supported) throw new Error('ZTools 2.4.0 or newer is required') const format = options.format === 'json' ? 'json' : options.format === 'markdown' ? 'markdown' : null if (!format) throw new TypeError('Unsupported report format') if (typeof options.content !== 'string') throw new TypeError('Report content must be text') @@ -108,11 +122,37 @@ async function saveReport(options = {}) { if (!filePath) return { canceled: true } await fs.writeFile(filePath, options.content, { encoding: 'utf8' }) + const realPath = await fs.realpath(filePath) + dragGrants.clear() + dragGrants.set(realPath, Date.now() + DRAG_GRANT_TTL_MS) return { canceled: false, filePath } } +async function startDrag(filePath) { + if (!compatibility.supported) return false + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) return false + const api = hostApi() + if (!api || typeof api.startDrag !== 'function') return false + try { + const realPath = await fs.realpath(filePath) + const expiresAt = dragGrants.get(realPath) + if (!expiresAt || expiresAt < Date.now()) { + dragGrants.delete(realPath) + return false + } + const stat = await fs.stat(realPath) + if (!stat.isFile()) return false + dragGrants.delete(realPath) + api.startDrag(realPath) + return true + } catch { + return false + } +} + window.systemReport = Object.freeze({ collect, copyText, - saveReport + saveReport, + startDrag }) diff --git a/plugins/system-diagnostic-report/src/App.vue b/plugins/system-diagnostic-report/src/App.vue index 9cc4953c5..3beb8736a 100644 --- a/plugins/system-diagnostic-report/src/App.vue +++ b/plugins/system-diagnostic-report/src/App.vue @@ -18,12 +18,16 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue' import StatusMark from './components/StatusMark.vue' import { formatReportDate, reportToJson, reportToMarkdown } from './composables/formatReport' import { useSystemReport } from './composables/useSystemReport' +import { hostCompatibility } from './composables/ztoolsCompatibility' import type { DiagnosticStatus } from './types/report' type Theme = 'light' | 'dark' type ExportFormat = 'markdown' | 'json' +const compatibility = ref(hostCompatibility()) const { report, loading, error, stale, usedMock, collect } = useSystemReport() +const lastExportPath = ref('') +const canStartDrag = computed(() => typeof window.ztools?.startDrag === 'function') const activeGroup = ref('overview') const theme = ref('light') const announcement = ref('') @@ -119,6 +123,7 @@ async function exportAs(format: ExportFormat) { if (window.systemReport?.saveReport) { const result = await window.systemReport.saveReport({ content, defaultName, format }) if (result.canceled) return + lastExportPath.value = result.filePath || '' } else { const blob = new Blob([content], { type: format === 'markdown' ? 'text/markdown;charset=utf-8' : 'application/json;charset=utf-8' }) const url = URL.createObjectURL(blob) @@ -135,6 +140,17 @@ async function exportAs(format: ExportFormat) { } } +async function dragLastExport(event: DragEvent) { + event.preventDefault() + if (!lastExportPath.value || !window.systemReport?.startDrag) return + try { + const started = await window.systemReport.startDrag(lastExportPath.value) + announce(started ? '已开始拖出导出文件' : '当前 ZTools 版本不支持拖出文件') + } catch { + announce('拖出文件失败,请在文件夹中打开') + } +} + function observeSections() { observer.value?.disconnect() observer.value = new IntersectionObserver( @@ -167,7 +183,20 @@ onMounted(async () => { // Use the operating-system preference. } applyTheme(initialTheme) - await refresh() + if (!compatibility.value.supported) return + window.ztools?.onPluginOut?.(() => { + // Collection is deliberately allowed to finish in preload. Closing only + // transient UI avoids duplicate collectors when 3.2 hides this renderer. + exportDialog.value?.close() + observer.value?.disconnect() + announcement.value = '' + }) + window.ztools?.onPluginEnter?.(() => { + if (!compatibility.value.supported) return + if (report.value) void nextTick(observeSections) + else if (!loading.value) void refresh() + }) + if (compatibility.value.supported) await refresh() }) onBeforeUnmount(() => { @@ -177,6 +206,15 @@ onBeforeUnmount(() => { diff --git a/plugins/system-diagnostic-report/src/composables/ztoolsCompatibility.ts b/plugins/system-diagnostic-report/src/composables/ztoolsCompatibility.ts new file mode 100644 index 000000000..437cb69fd --- /dev/null +++ b/plugins/system-diagnostic-report/src/composables/ztoolsCompatibility.ts @@ -0,0 +1,36 @@ +export type ZToolsCompatibility = { + supported: boolean + detected: boolean + version: string | null +} + +const MINIMUM_VERSION = Object.freeze([2, 4, 0]) + +function parseVersion(value: unknown): number[] | null { + if (typeof value !== 'string') return null + const match = value.trim().match(/^[vV]?(\d+)\.(\d+)(?:\.(\d+))?(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/) + if (!match) return null + const parts = [Number(match[1]), Number(match[2]), Number(match[3] || 0)] + return parts.every(Number.isSafeInteger) ? parts : null +} + +export function getZToolsCompatibility(value: unknown): ZToolsCompatibility { + const parsed = parseVersion(value) + if (!parsed) return { supported: false, detected: true, version: null } + const difference = parsed.findIndex((part, index) => part !== MINIMUM_VERSION[index]) + const minimumPrerelease = difference === -1 && /^[vV]?\d+\.\d+(?:\.\d+)?-/.test(String(value).trim()) + const supported = !minimumPrerelease && (difference === -1 || parsed[difference] > MINIMUM_VERSION[difference]) + return { supported, detected: true, version: `${parsed[0]}.${parsed[1]}.${parsed[2]}` } +} + +export function hostCompatibility(): ZToolsCompatibility { + if (!window.ztools) return { supported: true, detected: false, version: null } + if (typeof window.ztools.getAppVersion !== 'function') { + return { supported: false, detected: true, version: null } + } + try { + return getZToolsCompatibility(window.ztools.getAppVersion()) + } catch { + return { supported: false, detected: true, version: null } + } +} diff --git a/plugins/system-diagnostic-report/src/env.d.ts b/plugins/system-diagnostic-report/src/env.d.ts index b19351640..3148bdddb 100644 --- a/plugins/system-diagnostic-report/src/env.d.ts +++ b/plugins/system-diagnostic-report/src/env.d.ts @@ -12,6 +12,7 @@ declare global { defaultName: string format: 'markdown' | 'json' }): Promise<{ canceled: boolean; filePath?: string }> + startDrag?(filePath: string): Promise } ztools?: { copyText?(text: string): void @@ -19,6 +20,8 @@ declare global { getAppVersion?(): string getAllDisplays?(): unknown[] onPluginEnter?(callback: (action: unknown) => void): void + onPluginOut?(callback: () => void): void + startDrag?(files: string | string[]): unknown } } } diff --git a/plugins/system-diagnostic-report/src/styles.css b/plugins/system-diagnostic-report/src/styles.css index 1520757c8..dda2743b6 100644 --- a/plugins/system-diagnostic-report/src/styles.css +++ b/plugins/system-diagnostic-report/src/styles.css @@ -24,6 +24,21 @@ --utility: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } +.compatibility-gate { + min-height: 100vh; + display: grid; + place-content: center; + grid-template-columns: auto minmax(0, 520px); + gap: 20px; + padding: 36px; + color: var(--ink); + background: var(--paper); +} + +.compatibility-gate p { margin: 0 0 8px; color: var(--ink-soft); } +.compatibility-gate h1 { margin: 0 0 12px; font-size: clamp(22px, 4vw, 34px); line-height: 1.25; } +.compatibility-mark { display: grid; width: 46px; height: 46px; place-items: center; border-radius: 50%; color: white; background: #c2410c; font-size: 28px; font-weight: 700; } + :root[data-theme="dark"] { color: #dde4e5; background: #151b1e; diff --git a/plugins/system-diagnostic-report/tests/frontend.test.ts b/plugins/system-diagnostic-report/tests/frontend.test.ts index 4fcae6cc3..a1b98c687 100644 --- a/plugins/system-diagnostic-report/tests/frontend.test.ts +++ b/plugins/system-diagnostic-report/tests/frontend.test.ts @@ -3,6 +3,7 @@ import test from 'node:test' import { escapeMarkdown, reportToMarkdown } from '../src/composables/formatReport' import { normalizeReport, useSystemReport } from '../src/composables/useSystemReport' +import { getZToolsCompatibility, hostCompatibility } from '../src/composables/ztoolsCompatibility' import type { NormalizedReport, SystemReport } from '../src/types/report' function rawReport(generatedAt = '2026-07-30T10:20:30.000Z'): SystemReport { @@ -26,6 +27,36 @@ test('normalization uses canonical overview time and keeps false fields neutral' assert.equal(virtual?.status, 'neutral') }) +test('ZTools 2.4 is the supported floor while an unreadable detected version fails closed', () => { + assert.equal(getZToolsCompatibility('2.3.9').supported, false) + assert.equal(getZToolsCompatibility('2.4.0-beta.1').supported, false) + assert.equal(getZToolsCompatibility('2.4.0').supported, true) + assert.equal(getZToolsCompatibility('3.1.9').supported, true) + assert.equal(getZToolsCompatibility('3.2.0').supported, true) + assert.deepEqual(getZToolsCompatibility(undefined), { supported: false, detected: true, version: null }) + assert.deepEqual(getZToolsCompatibility('current'), { supported: false, detected: true, version: null }) +}) + +test('host compatibility allows only a bridge-free browser preview to have an unknown version', () => { + const previousWindow = globalThis.window + try { + Object.defineProperty(globalThis, 'window', { configurable: true, writable: true, value: {} }) + assert.deepEqual(hostCompatibility(), { supported: true, detected: false, version: null }) + for (const ztools of [ + {}, + { getAppVersion() { throw new Error('unavailable') } }, + { getAppVersion: () => '' }, + { getAppVersion: () => 'invalid' }, + ]) { + globalThis.window = { ztools } as Window & typeof globalThis + assert.deepEqual(hostCompatibility(), { supported: false, detected: true, version: null }) + } + } finally { + if (previousWindow === undefined) delete (globalThis as { window?: Window }).window + else globalThis.window = previousWindow + } +}) + test('collector errors retain their source without being duplicated as warnings', () => { const report = normalizeReport({ ...rawReport(), diff --git a/plugins/system-diagnostic-report/tests/host-compatibility.test.cjs b/plugins/system-diagnostic-report/tests/host-compatibility.test.cjs new file mode 100644 index 000000000..b7d830c9c --- /dev/null +++ b/plugins/system-diagnostic-report/tests/host-compatibility.test.cjs @@ -0,0 +1,50 @@ +'use strict' + +const assert = require('node:assert/strict') +const { spawnSync } = require('node:child_process') +const path = require('node:path') +const test = require('node:test') + +const { getHostCompatibility } = require('../public/preload/host-compatibility.cjs') + +test('browser preview is allowed and detected hosts fail closed on unreadable versions', () => { + assert.deepEqual(getHostCompatibility({}), { supported: true, detected: false, version: null }) + for (const api of [ + {}, + { getAppVersion() { throw new Error('unavailable') } }, + { getAppVersion: () => '' }, + { getAppVersion: () => 'current' }, + { getAppVersion: () => '3' }, + { getAppVersion: () => null }, + ]) { + assert.deepEqual(getHostCompatibility({ ztools: api }), { supported: false, detected: true, version: null }) + } +}) + +test('2.4 through 3.1 remain supported while older hosts are gated', () => { + assert.equal(getHostCompatibility({ ztools: { getAppVersion: () => '2.3.9' } }).supported, false) + assert.equal(getHostCompatibility({ ztools: { getAppVersion: () => '2.4.0-beta.1' } }).supported, false) + for (const version of ['2.4.0', '2.8.3', '3.0.0', '3.1.9']) { + assert.deepEqual(getHostCompatibility({ ztools: { getAppVersion: () => version } }), { + supported: true, + detected: true, + version, + }) + } +}) + +test('unsupported detected hosts do not load systeminformation or collector modules', () => { + const child = path.join(__dirname, 'preload-gate-child.cjs') + const services = path.join(__dirname, '..', 'public', 'preload', 'services.js') + for (const mode of ['missing', 'throwing', 'empty', 'invalid', 'below-floor']) { + const result = spawnSync(process.execPath, [child, services, mode], { + cwd: path.join(__dirname, '..'), + encoding: 'utf8', + timeout: 10_000, + }) + assert.equal(result.status, 0, `${mode}: ${result.stderr}`) + const state = JSON.parse(result.stdout) + assert.deepEqual(state.heavyLoads, [], mode) + assert.deepEqual(state.bridgeMethods, ['collect', 'copyText', 'saveReport', 'startDrag'], mode) + } +}) diff --git a/plugins/system-diagnostic-report/tests/manifest.test.cjs b/plugins/system-diagnostic-report/tests/manifest.test.cjs index e3860269b..adc8332ce 100644 --- a/plugins/system-diagnostic-report/tests/manifest.test.cjs +++ b/plugins/system-diagnostic-report/tests/manifest.test.cjs @@ -10,7 +10,7 @@ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) test('manifest defines the fixed plugin identity and entry points', () => { assert.equal(manifest.name, 'system-diagnostic-report') assert.equal(manifest.title, '系统诊断报告') - assert.equal(manifest.version, '0.1.0') + assert.equal(manifest.version, '0.1.1') assert.equal(manifest.main, 'index.html') assert.equal(manifest.preload, 'preload/services.js') assert.equal(manifest.logo, 'logo.svg') @@ -65,5 +65,5 @@ test('preload exposes only the minimum report bridge', () => { .split(',') .map((value) => value.trim()) .filter(Boolean) - assert.deepEqual(methods, ['collect', 'copyText', 'saveReport']) + assert.deepEqual(methods, ['collect', 'copyText', 'saveReport', 'startDrag']) }) diff --git a/plugins/system-diagnostic-report/tests/preload-gate-child.cjs b/plugins/system-diagnostic-report/tests/preload-gate-child.cjs new file mode 100644 index 000000000..154fe9237 --- /dev/null +++ b/plugins/system-diagnostic-report/tests/preload-gate-child.cjs @@ -0,0 +1,28 @@ +'use strict' + +const Module = require('node:module') +const path = require('node:path') + +const [servicesPath, mode] = process.argv.slice(2) +if (!path.isAbsolute(servicesPath)) process.exit(2) + +const ztools = {} +if (mode === 'throwing') ztools.getAppVersion = () => { throw new Error('unavailable') } +else if (mode === 'empty') ztools.getAppVersion = () => '' +else if (mode === 'invalid') ztools.getAppVersion = () => 'ZTools current' +else if (mode === 'below-floor') ztools.getAppVersion = () => '2.3.9' + +const heavyLoads = [] +const originalLoad = Module._load +Module._load = function trackedLoad(request, parent, isMain) { + if (request === 'systeminformation' || request === './collectors/core.cjs') heavyLoads.push(request) + return originalLoad.call(this, request, parent, isMain) +} + +global.window = { ztools } +require(servicesPath) + +process.stdout.write(JSON.stringify({ + heavyLoads, + bridgeMethods: Object.keys(window.systemReport || {}), +}))