Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions plugins/system-diagnostic-report/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

- 首次发布本地系统与硬件信息采集、脱敏展示及诊断报告导出能力。
6 changes: 6 additions & 0 deletions plugins/system-diagnostic-report/README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion plugins/system-diagnostic-report/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "system-diagnostic-report",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"description": "本地生成软硬件系统信息与脱敏诊断报告的 ZTools 插件。",
Expand Down
2 changes: 1 addition & 1 deletion plugins/system-diagnostic-report/public/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 })
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "system-diagnostic-report-preload",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "commonjs",
"main": "services.js",
Expand Down
48 changes: 44 additions & 4 deletions plugins/system-diagnostic-report/public/preload/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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')
Expand Down Expand Up @@ -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
})
45 changes: 44 additions & 1 deletion plugins/system-diagnostic-report/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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<Theme>('light')
const announcement = ref('')
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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(() => {
Expand All @@ -177,6 +206,15 @@ onBeforeUnmount(() => {
</script>

<template>
<section v-if="!compatibility.supported" class="compatibility-gate" role="alert">
<span class="compatibility-mark" aria-hidden="true">!</span>
<div>
<p>需要更新 ZTools</p>
<h1>当前版本 {{ compatibility.version || '无法识别' }} 暂不支持此插件</h1>
<p>为了获得更完整、稳定的体验,请升级至 ZTools 2.4.0 或更高版本。</p>
</div>
</section>
<template v-else>
<a class="skip-link" href="#report-content">跳到诊断内容</a>
<div class="app-frame">
<header class="topbar">
Expand Down Expand Up @@ -206,6 +244,10 @@ onBeforeUnmount(() => {
<Download :size="15" aria-hidden="true" />
<span>导出</span>
</button>
<button v-if="lastExportPath && canStartDrag" class="text-button" type="button" draggable="true" title="按住并拖到外部应用" @dragstart="dragLastExport">
<Download :size="15" aria-hidden="true" />
<span>拖出上次导出</span>
</button>
</div>
</header>

Expand Down Expand Up @@ -372,4 +414,5 @@ onBeforeUnmount(() => {
</div>
<div class="dialog-safe"><ShieldCheck :size="14" aria-hidden="true" /> 敏感标识仍保持隐藏</div>
</dialog>
</template>
</template>
Original file line number Diff line number Diff line change
@@ -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 }
}
}
3 changes: 3 additions & 0 deletions plugins/system-diagnostic-report/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ declare global {
defaultName: string
format: 'markdown' | 'json'
}): Promise<{ canceled: boolean; filePath?: string }>
startDrag?(filePath: string): Promise<boolean>
}
ztools?: {
copyText?(text: string): void
showSaveDialog?(options: unknown): Promise<{ canceled: boolean; filePath?: string }>
getAppVersion?(): string
getAllDisplays?(): unknown[]
onPluginEnter?(callback: (action: unknown) => void): void
onPluginOut?(callback: () => void): void
startDrag?(files: string | string[]): unknown
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions plugins/system-diagnostic-report/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
31 changes: 31 additions & 0 deletions plugins/system-diagnostic-report/tests/frontend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(),
Expand Down
Loading
Loading