From 03a189ec3f4bfdcca5042515e195daa060fbca74 Mon Sep 17 00:00:00 2001 From: w-jian-q <3066225143@qq.com> Date: Mon, 17 Aug 2026 20:50:24 +0800 Subject: [PATCH] feat(scan): add safe cleanup planning and JSON reports --- WindowsAppDataManager/CMakeLists.txt | 14 +- .../components/CleanupPlanDialog.qml | 198 +++++++++++ .../components/DataCategoryRow.qml | 21 ++ .../components/InlineNotice.qml | 31 ++ .../components/PathField.qml | 70 ++-- .../components/ScanIssuesDialog.qml | 179 ++++++++++ WindowsAppDataManager/main.qml | 51 +++ WindowsAppDataManager/pages/OverviewPage.qml | 37 +- .../rules/builtin/microsoft-edge.json | 75 ++++ .../rules/builtin/obs-studio.json | 75 ++++ .../src/core/rules/RuleCatalog.cpp | 2 + .../src/qmlmodels/ApplicationListModel.cpp | 5 + .../src/qmlmodels/ApplicationListModel.h | 1 + .../src/qmlmodels/ScanViewModel.cpp | 151 +++++++- .../src/qmlmodels/ScanViewModel.h | 16 + .../src/services/CleanupPlanBuilder.cpp | 51 +++ .../src/services/CleanupPlanBuilder.h | 30 ++ .../src/services/ScanReportExporter.cpp | 332 ++++++++++++++++++ .../src/services/ScanReportExporter.h | 17 + WindowsAppDataManager/tests/tst_backend.cpp | 139 +++++++- 20 files changed, 1462 insertions(+), 33 deletions(-) create mode 100644 WindowsAppDataManager/components/CleanupPlanDialog.qml create mode 100644 WindowsAppDataManager/components/ScanIssuesDialog.qml create mode 100644 WindowsAppDataManager/rules/builtin/microsoft-edge.json create mode 100644 WindowsAppDataManager/rules/builtin/obs-studio.json create mode 100644 WindowsAppDataManager/src/services/CleanupPlanBuilder.cpp create mode 100644 WindowsAppDataManager/src/services/CleanupPlanBuilder.h create mode 100644 WindowsAppDataManager/src/services/ScanReportExporter.cpp create mode 100644 WindowsAppDataManager/src/services/ScanReportExporter.h diff --git a/WindowsAppDataManager/CMakeLists.txt b/WindowsAppDataManager/CMakeLists.txt index 4fc349d..255fc9c 100644 --- a/WindowsAppDataManager/CMakeLists.txt +++ b/WindowsAppDataManager/CMakeLists.txt @@ -13,6 +13,7 @@ find_package(Qt${QT_VERSION_MAJOR} Qml Quick QuickControls2 + QuickDialogs2 REQUIRED ) qt_standard_project_setup() @@ -49,6 +50,10 @@ set(BACKEND_SOURCES src/platform/windows/registry/InstalledApplicationRegistry.h src/services/ScanService.cpp src/services/ScanService.h + src/services/CleanupPlanBuilder.cpp + src/services/CleanupPlanBuilder.h + src/services/ScanReportExporter.cpp + src/services/ScanReportExporter.h src/services/SettingsService.cpp src/services/SettingsService.h ) @@ -68,7 +73,9 @@ if(QT_VERSION_MAJOR EQUAL 6) rules/schema.json rules/builtin/chrome.json rules/builtin/chromium.json + rules/builtin/microsoft-edge.json rules/builtin/discord.json + rules/builtin/obs-studio.json rules/builtin/vscode.json rules/builtin/jetbrains.json rules/builtin/windows-temp.json @@ -111,6 +118,8 @@ if(QT_VERSION VERSION_GREATER_EQUAL 6.2) components/EmptyState.qml components/InlineNotice.qml components/ScanProgressStrip.qml + components/ScanIssuesDialog.qml + components/CleanupPlanDialog.qml components/OverviewStats.qml components/DispositionSummary.qml components/OverviewApplicationList.qml @@ -192,6 +201,7 @@ target_link_libraries(${PROJECT_NAME} Qt::Qml Qt::Quick Qt::QuickControls2 + Qt::QuickDialogs2 ) target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/qmlmodels @@ -228,9 +238,7 @@ endif() if(WIN32 AND QT_VERSION_MAJOR EQUAL 6 AND TARGET Qt6::windeployqt) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD - COMMAND ${CMAKE_COMMAND} -E env - "PATH=$;$ENV{PATH}" - $ + COMMAND $ $,--debug,--release> --qmldir "${CMAKE_CURRENT_SOURCE_DIR}" --no-translations diff --git a/WindowsAppDataManager/components/CleanupPlanDialog.qml b/WindowsAppDataManager/components/CleanupPlanDialog.qml new file mode 100644 index 0000000..bc47c6d --- /dev/null +++ b/WindowsAppDataManager/components/CleanupPlanDialog.qml @@ -0,0 +1,198 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: dialog + + property var items: [] + property string totalText: "0 B" + + modal: true + focus: true + width: Math.min(760, parent ? parent.width - 56 : 760) + height: Math.min(640, parent ? parent.height - 72 : 640) + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + padding: 0 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + radius: Theme.radiusLarge + color: Theme.surface + border.width: 1 + border.color: Theme.border + } + + header: Rectangle { + implicitHeight: 58 + color: Theme.surface + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 8 + spacing: 10 + + Rectangle { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + radius: Theme.radiusSmall + color: Theme.greenSoft + + ThemedIcon { + anchors.centerIn: parent + width: 16 + height: 16 + source: Qt.resolvedUrl("../resources/Icons/IcBaselineCleaningServices.svg") + color: Theme.greenText + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + text: "清理计划" + color: Theme.textPrimary + font.pixelSize: 15 + font.weight: Font.DemiBold + } + + Text { + text: dialog.items.length + " 项候选,合计 " + dialog.totalText + color: Theme.textMuted + font.pixelSize: 10 + } + } + + IconButton { + iconSource: Qt.resolvedUrl("../resources/Icons/TablerX.svg") + tooltip: "关闭" + onClicked: dialog.close() + } + } + } + + contentItem: ColumnLayout { + spacing: 0 + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: Theme.divider } + + Rectangle { + Layout.fillWidth: true + Layout.margins: 16 + Layout.bottomMargin: 10 + implicitHeight: previewNotice.implicitHeight + 16 + radius: Theme.radiusSmall + color: Theme.accentSoft + + Text { + id: previewNotice + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + text: "这是只读预览,不会删除任何文件。仅列出规则明确、可重新生成,且风险为安全或低风险的数据。" + color: Theme.accentText + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + } + + ListView { + id: planList + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 14 + clip: true + spacing: 8 + model: dialog.items + ScrollBar.vertical: ScrollBar { } + + delegate: Rectangle { + id: planRow + + required property var modelData + width: planList.width + height: planContent.implicitHeight + 18 + radius: Theme.radiusMedium + color: Theme.surfaceRaised + border.width: 1 + border.color: Theme.border + + Column { + id: planContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 10 + spacing: 6 + + RowLayout { + width: parent.width + spacing: 8 + + Text { + Layout.fillWidth: true + text: planRow.modelData.applicationName + " · " + + planRow.modelData.categoryText + color: Theme.textPrimary + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + text: planRow.modelData.sizeText + color: Theme.textPrimary + font.pixelSize: 11 + font.weight: Font.Medium + } + + RiskBadge { + level: planRow.modelData.riskLevel + label: planRow.modelData.riskText + } + } + + Text { + width: parent.width + text: planRow.modelData.impact + color: Theme.textSecondary + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + + PathField { + width: parent.width + label: "路径" + value: planRow.modelData.path + } + + Text { + width: parent.width + text: "依据 " + planRow.modelData.ruleSource + color: Theme.textMuted + font.pixelSize: 10 + elide: Text.ElideRight + } + } + } + + Text { + anchors.centerIn: parent + visible: planList.count === 0 + text: "当前没有符合安全边界的可重建数据。" + color: Theme.textMuted + font.pixelSize: 12 + } + } + } +} diff --git a/WindowsAppDataManager/components/DataCategoryRow.qml b/WindowsAppDataManager/components/DataCategoryRow.qml index e04c1eb..f63651a 100644 --- a/WindowsAppDataManager/components/DataCategoryRow.qml +++ b/WindowsAppDataManager/components/DataCategoryRow.qml @@ -6,6 +6,7 @@ Rectangle { required property var rowData property bool expanded: false + readonly property int unknownRiskLevel: 5 function toggleExpanded() { expanded = !expanded @@ -151,6 +152,26 @@ Rectangle { font.pixelSize: 10 elide: Text.ElideRight } + + Rectangle { + width: parent.width + height: unknownExplanation.implicitHeight + 14 + visible: categoryRow.rowData.riskLevel === categoryRow.unknownRiskLevel + radius: Theme.radiusSmall + color: Theme.neutralSoft + + Text { + id: unknownExplanation + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 7 + text: "为什么暂时不能判断:当前只获得目录名称或路径特征,缺少足够的应用归属证据。该项已被读取,但不代表可以安全处理。" + color: Theme.textSecondary + font.pixelSize: 10 + wrapMode: Text.WordWrap + } + } } Behavior on height { diff --git a/WindowsAppDataManager/components/InlineNotice.qml b/WindowsAppDataManager/components/InlineNotice.qml index 5b590cc..7238b47 100644 --- a/WindowsAppDataManager/components/InlineNotice.qml +++ b/WindowsAppDataManager/components/InlineNotice.qml @@ -1,4 +1,5 @@ import QtQuick +import QtQuick.Controls import QtQuick.Layouts Rectangle { @@ -11,8 +12,11 @@ Rectangle { property url actionIconSource property string actionTooltip: "" property bool actionVisible: false + property string detailActionText: "" + property bool detailActionVisible: false signal actionRequested() + signal detailActionRequested() implicitHeight: 44 radius: Theme.radiusSmall @@ -40,6 +44,33 @@ Rectangle { elide: Text.ElideMiddle } + Button { + id: detailAction + + visible: notice.detailActionVisible + text: notice.detailActionText + hoverEnabled: true + padding: 0 + font.pixelSize: 11 + font.weight: Font.DemiBold + + contentItem: Text { + text: detailAction.text + color: notice.accent + font.pixelSize: 11 + font.weight: Font.DemiBold + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + radius: Theme.radiusSmall + color: detailAction.down ? Theme.surfaceSelected + : detailAction.hovered ? Theme.surfaceHover : "transparent" + } + + onClicked: notice.detailActionRequested() + } + IconButton { visible: notice.actionVisible iconSource: notice.actionIconSource diff --git a/WindowsAppDataManager/components/PathField.qml b/WindowsAppDataManager/components/PathField.qml index 5873bda..16e0af0 100644 --- a/WindowsAppDataManager/components/PathField.qml +++ b/WindowsAppDataManager/components/PathField.qml @@ -22,35 +22,57 @@ Item { font.weight: Font.DemiBold } - TextField { - id: pathText - + Row { width: parent.width height: 25 - text: field.value - readOnly: true - selectByMouse: true - activeFocusOnTab: true - color: Theme.textSecondary - selectionColor: Theme.accent - selectedTextColor: Theme.onAccent - font.pixelSize: 11 - leftPadding: 2 - rightPadding: 2 - topPadding: 0 - bottomPadding: 0 - clip: true + spacing: 4 + + TextField { + id: pathText + + width: parent.width - copyButton.width - 4 + height: 25 + text: field.value + readOnly: true + selectByMouse: true + activeFocusOnTab: true + color: Theme.textSecondary + selectionColor: Theme.accent + selectedTextColor: Theme.onAccent + font.pixelSize: 11 + leftPadding: 2 + rightPadding: 2 + topPadding: 0 + bottomPadding: 0 + clip: true - background: Rectangle { - color: "transparent" - border.width: pathText.activeFocus ? 1 : 0 - border.color: Theme.accent - radius: Theme.radiusSmall + background: Rectangle { + color: "transparent" + border.width: pathText.activeFocus ? 1 : 0 + border.color: Theme.accent + radius: Theme.radiusSmall + } + + Accessible.role: Accessible.EditableText + Accessible.name: field.label + Accessible.description: field.value } - Accessible.role: Accessible.EditableText - Accessible.name: field.label - Accessible.description: field.value + IconButton { + id: copyButton + + width: 32 + height: 25 + iconSize: 15 + iconSource: Qt.resolvedUrl("../resources/Icons/TablerFiles.svg") + tooltip: "复制路径" + symbolColor: Theme.textMuted + onClicked: { + pathText.selectAll() + pathText.copy() + pathText.deselect() + } + } } } diff --git a/WindowsAppDataManager/components/ScanIssuesDialog.qml b/WindowsAppDataManager/components/ScanIssuesDialog.qml new file mode 100644 index 0000000..6a4452e --- /dev/null +++ b/WindowsAppDataManager/components/ScanIssuesDialog.qml @@ -0,0 +1,179 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Dialog { + id: dialog + + property var issues: [] + property bool scanning: false + + signal rescanRequested() + + modal: true + focus: true + width: Math.min(720, parent ? parent.width - 56 : 720) + height: Math.min(600, parent ? parent.height - 72 : 600) + x: parent ? Math.round((parent.width - width) / 2) : 0 + y: parent ? Math.round((parent.height - height) / 2) : 0 + padding: 0 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + radius: Theme.radiusLarge + color: Theme.surface + border.width: 1 + border.color: Theme.border + } + + header: Rectangle { + implicitHeight: 58 + color: Theme.surface + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 18 + anchors.rightMargin: 8 + spacing: 10 + + Rectangle { + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + radius: Theme.radiusSmall + color: Theme.amberSoft + + ThemedIcon { + anchors.centerIn: parent + width: 16 + height: 16 + source: Qt.resolvedUrl("../resources/Icons/TablerExclamationMark.svg") + color: Theme.amberText + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + Text { + text: "未完整读取的位置" + color: Theme.textPrimary + font.pixelSize: 15 + font.weight: Font.DemiBold + } + + Text { + text: dialog.issues.length + " 个位置需要注意;路径字段可直接选中并复制。" + color: Theme.textMuted + font.pixelSize: 10 + } + } + + IconButton { + visible: !dialog.scanning + iconSource: Qt.resolvedUrl("../resources/Icons/TablerRefresh.svg") + tooltip: "重新扫描" + symbolColor: Theme.amberText + onClicked: dialog.rescanRequested() + } + + IconButton { + iconSource: Qt.resolvedUrl("../resources/Icons/TablerX.svg") + tooltip: "关闭" + onClicked: dialog.close() + } + } + } + + contentItem: ColumnLayout { + spacing: 0 + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: Theme.divider } + + Text { + Layout.fillWidth: true + Layout.margins: 16 + text: "这些位置没有纳入完整统计。请先确认路径和原因;不要因为它们显示为未知就直接处理。" + color: Theme.textSecondary + font.pixelSize: 11 + wrapMode: Text.WordWrap + } + + ListView { + id: issueList + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 14 + clip: true + spacing: 8 + model: dialog.issues + ScrollBar.vertical: ScrollBar { } + + delegate: Rectangle { + id: issueRow + + required property var modelData + width: issueList.width + height: issueContent.implicitHeight + 18 + radius: Theme.radiusMedium + color: Theme.surfaceRaised + border.width: 1 + border.color: Theme.border + + Column { + id: issueContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 10 + spacing: 6 + + RowLayout { + width: parent.width + spacing: 8 + + Text { + Layout.fillWidth: true + text: issueRow.modelData.message + color: Theme.textPrimary + font.pixelSize: 12 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + text: issueRow.modelData.codeText + color: Theme.amberText + font.pixelSize: 10 + font.weight: Font.Medium + } + } + + PathField { width: parent.width; label: "位置"; value: issueRow.modelData.path } + + Text { + width: parent.width + visible: issueRow.modelData.technicalDetail.length > 0 + text: "技术详情 " + issueRow.modelData.technicalDetail + color: Theme.textMuted + font.pixelSize: 10 + wrapMode: Text.WrapAnywhere + } + } + } + + Text { + anchors.centerIn: parent + visible: issueList.count === 0 + text: "当前没有需要展示的位置。" + color: Theme.textMuted + font.pixelSize: 12 + } + } + } +} diff --git a/WindowsAppDataManager/main.qml b/WindowsAppDataManager/main.qml index 32fcc17..2e63bda 100644 --- a/WindowsAppDataManager/main.qml +++ b/WindowsAppDataManager/main.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import QtQuick.Dialogs import QtQuick.Layouts import windowsappdatamanager @@ -49,6 +50,8 @@ ApplicationWindow { readonly property int sidebarWidth: width < 1120 ? 184 : 208 property real detailPanelExtent: showDetails ? detailPanelWidth : 0 property bool detailPaneOpen: false + property string exportMessage: "" + property bool exportSucceeded: false onCurrentPageChanged: pageFade.restart() readonly property color scanStatusColor: scanning ? Theme.accentText @@ -67,6 +70,10 @@ ApplicationWindow { : Qt.resolvedUrl("resources/Icons/TablerPointFilled.svg") function toggleScan() { + if (!scanController.running) { + exportMessage = "" + exportSucceeded = false + } scanController.toggleScan() } @@ -187,7 +194,15 @@ ApplicationWindow { scanFailed: window.scanController.errorMessage.length > 0 partialResult: window.scanController.partialResult issueCount: window.scanController.issueCount + exportMessage: window.exportMessage + exportSucceeded: window.exportSucceeded onScanRequested: window.toggleScan() + onIssuesRequested: scanIssuesDialog.open() + onExportRequested: exportDialog.open() + onCleanupPlanRequested: { + window.scanController.generateCleanupPlan() + cleanupPlanDialog.open() + } onApplicationSelected: index => window.openApplication(index) onApplicationsRequested: window.currentPage = window.applicationsPageIndex } @@ -231,6 +246,42 @@ ApplicationWindow { } } + ScanIssuesDialog { + id: scanIssuesDialog + + parent: Overlay.overlay + issues: window.scanController.issues + scanning: window.scanning + onRescanRequested: { + close() + window.toggleScan() + } + } + + CleanupPlanDialog { + id: cleanupPlanDialog + + parent: Overlay.overlay + items: window.scanController.cleanupPlan + totalText: window.scanController.cleanupPlanTotalText + } + + FileDialog { + id: exportDialog + + title: "导出 AppData 扫描报告" + fileMode: FileDialog.SaveFile + nameFilters: ["CSV 报告 (*.csv)", "JSON 报告 (*.json)"] + defaultSuffix: selectedNameFilter.indexOf("JSON") >= 0 ? "json" : "csv" + onAccepted: { + const error = window.scanController.exportReport(selectedFile) + window.exportSucceeded = error.length === 0 + window.exportMessage = window.exportSucceeded + ? "扫描报告已导出。" + : "导出失败:" + error + } + } + NumberAnimation { id: pageFade target: contentStack diff --git a/WindowsAppDataManager/pages/OverviewPage.qml b/WindowsAppDataManager/pages/OverviewPage.qml index 874fda6..b2a663b 100644 --- a/WindowsAppDataManager/pages/OverviewPage.qml +++ b/WindowsAppDataManager/pages/OverviewPage.qml @@ -13,8 +13,13 @@ Flickable { property bool scanFailed: false property bool partialResult: false property int issueCount: 0 + property string exportMessage: "" + property bool exportSucceeded: false signal scanRequested() + signal issuesRequested() + signal exportRequested() + signal cleanupPlanRequested() signal applicationSelected(int index) signal applicationsRequested() @@ -68,6 +73,20 @@ Flickable { prominent: true onClicked: page.scanRequested() } + + IconButton { + visible: page.hasResults && !page.scanning + iconSource: Qt.resolvedUrl("../resources/Icons/TablerFileFilled.svg") + tooltip: "导出扫描报告" + onClicked: page.exportRequested() + } + + IconButton { + visible: page.hasResults && !page.scanning && !page.partialResult + iconSource: Qt.resolvedUrl("../resources/Icons/IcBaselineCleaningServices.svg") + tooltip: "生成清理计划" + onClicked: page.cleanupPlanRequested() + } } ScanProgressStrip { @@ -81,7 +100,8 @@ Flickable { InlineNotice { width: parent.width height: implicitHeight - visible: page.hasResults && (page.scanFailed || page.partialResult) + visible: page.hasResults && !page.scanning + && (page.scanFailed || page.partialResult) iconSource: Qt.resolvedUrl("../resources/Icons/TablerExclamationMark.svg") message: page.scanFailed ? "本次扫描未完成,当前仍显示上一次完整结果。" @@ -91,7 +111,22 @@ Flickable { actionIconSource: Qt.resolvedUrl("../resources/Icons/TablerRefresh.svg") actionTooltip: "重新扫描" actionVisible: !page.scanning + detailActionText: "查看明细" + detailActionVisible: page.partialResult && page.issueCount > 0 onActionRequested: page.scanRequested() + onDetailActionRequested: page.issuesRequested() + } + + InlineNotice { + width: parent.width + height: implicitHeight + visible: page.exportMessage.length > 0 + iconSource: Qt.resolvedUrl(page.exportSucceeded + ? "../resources/Icons/TablerCheck.svg" + : "../resources/Icons/TablerExclamationMark.svg") + message: page.exportMessage + accent: page.exportSucceeded ? Theme.greenText : Theme.redText + fill: page.exportSucceeded ? Theme.greenSoft : Theme.redSoft } EmptyState { diff --git a/WindowsAppDataManager/rules/builtin/microsoft-edge.json b/WindowsAppDataManager/rules/builtin/microsoft-edge.json new file mode 100644 index 0000000..d1406ba --- /dev/null +++ b/WindowsAppDataManager/rules/builtin/microsoft-edge.json @@ -0,0 +1,75 @@ +{ + "$schema": "../schema.json", + "id": "microsoft-edge", + "version": "1", + "name": "Microsoft Edge", + "publisher": "Microsoft Corporation", + "applicationCategory": "浏览器", + "executablePath": "%ProgramFiles(x86)%/Microsoft/Edge/Application/msedge.exe", + "installPath": "%ProgramFiles(x86)%/Microsoft/Edge/Application", + "identifiers": { + "registryDisplayNames": ["Microsoft Edge"], + "registryPublishers": ["Microsoft Corporation"] + }, + "locations": [ + { "scope": "local", "path": "Microsoft/Edge/User Data" } + ], + "entries": [ + { + "id": "default-cache", + "path": "Default/Cache", + "category": "cache", + "risk": "safe", + "rebuildable": true, + "impact": "网页资源会在后续浏览时按需重新下载,首次访问可能稍慢。" + }, + { + "id": "default-code-cache", + "path": "Default/Code Cache", + "category": "cache", + "risk": "safe", + "rebuildable": true, + "impact": "脚本编译缓存会在后续浏览过程中重新生成。" + }, + { + "id": "default-gpu-cache", + "path": "Default/GPUCache", + "category": "cache", + "risk": "safe", + "rebuildable": true, + "impact": "图形缓存会重新生成,首次渲染可能稍慢。" + }, + { + "id": "shader-cache", + "path": "ShaderCache", + "category": "cache", + "risk": "safe", + "rebuildable": true, + "impact": "着色器缓存会在需要时重新生成。" + }, + { + "id": "crash-reports", + "path": "Crashpad/reports", + "category": "crash-dump", + "risk": "low", + "rebuildable": false, + "impact": "旧崩溃报告会丢失,不影响浏览器正常启动。" + }, + { + "id": "default-session", + "path": "Default/Session Storage", + "category": "session", + "risk": "high", + "rebuildable": false, + "impact": "可能导致退出登录或丢失未同步的网页状态。" + }, + { + "id": "default-credentials", + "path": "Default/Login Data", + "category": "credential", + "risk": "protected", + "rebuildable": false, + "impact": "包含登录凭据相关数据,默认禁止处理。" + } + ] +} diff --git a/WindowsAppDataManager/rules/builtin/obs-studio.json b/WindowsAppDataManager/rules/builtin/obs-studio.json new file mode 100644 index 0000000..979b7c8 --- /dev/null +++ b/WindowsAppDataManager/rules/builtin/obs-studio.json @@ -0,0 +1,75 @@ +{ + "$schema": "../schema.json", + "id": "obs-studio", + "version": "1", + "name": "OBS Studio", + "publisher": "OBS Project", + "applicationCategory": "音视频工具", + "executablePath": "%ProgramFiles%/obs-studio/bin/64bit/obs64.exe", + "installPath": "%ProgramFiles%/obs-studio", + "identifiers": { + "registryDisplayNames": ["OBS Studio"], + "registryPublishers": ["OBS Project"] + }, + "locations": [ + { "scope": "roaming", "path": "obs-studio" } + ], + "entries": [ + { + "id": "browser-cache", + "path": "plugin_config/obs-browser/Cache", + "category": "cache", + "risk": "safe", + "rebuildable": true, + "impact": "浏览器源的网页资源会在后续使用时重新下载。" + }, + { + "id": "browser-code-cache", + "path": "plugin_config/obs-browser/Code Cache", + "category": "cache", + "risk": "safe", + "rebuildable": true, + "impact": "浏览器源的脚本缓存会在后续使用时重新生成。" + }, + { + "id": "browser-gpu-cache", + "path": "plugin_config/obs-browser/GPUCache", + "category": "cache", + "risk": "safe", + "rebuildable": true, + "impact": "浏览器源的图形缓存会重新生成。" + }, + { + "id": "logs", + "path": "logs", + "category": "log", + "risk": "low", + "rebuildable": true, + "impact": "旧诊断日志会移除,OBS 后续仍会生成新日志。" + }, + { + "id": "crashes", + "path": "crashes", + "category": "crash-dump", + "risk": "low", + "rebuildable": false, + "impact": "旧崩溃记录会丢失,不影响 OBS 的配置和场景。" + }, + { + "id": "profiles", + "path": "basic/profiles", + "category": "config", + "risk": "caution", + "rebuildable": false, + "impact": "包含录制、推流和设备配置,不应自动处理。" + }, + { + "id": "scenes", + "path": "basic/scenes", + "category": "user-data", + "risk": "high", + "rebuildable": false, + "impact": "包含场景集合和来源配置,不应自动处理。" + } + ] +} diff --git a/WindowsAppDataManager/src/core/rules/RuleCatalog.cpp b/WindowsAppDataManager/src/core/rules/RuleCatalog.cpp index 1092016..774cd0a 100644 --- a/WindowsAppDataManager/src/core/rules/RuleCatalog.cpp +++ b/WindowsAppDataManager/src/core/rules/RuleCatalog.cpp @@ -150,7 +150,9 @@ const RuleCatalog &RuleCatalog::builtIn() static const QStringList resourcePaths { QStringLiteral(":/windowsappdatamanager/rules/builtin/chrome.json"), QStringLiteral(":/windowsappdatamanager/rules/builtin/chromium.json"), + QStringLiteral(":/windowsappdatamanager/rules/builtin/microsoft-edge.json"), QStringLiteral(":/windowsappdatamanager/rules/builtin/discord.json"), + QStringLiteral(":/windowsappdatamanager/rules/builtin/obs-studio.json"), QStringLiteral(":/windowsappdatamanager/rules/builtin/vscode.json"), QStringLiteral(":/windowsappdatamanager/rules/builtin/jetbrains.json"), QStringLiteral(":/windowsappdatamanager/rules/builtin/windows-temp.json"), diff --git a/WindowsAppDataManager/src/qmlmodels/ApplicationListModel.cpp b/WindowsAppDataManager/src/qmlmodels/ApplicationListModel.cpp index 583a6b2..38fc333 100644 --- a/WindowsAppDataManager/src/qmlmodels/ApplicationListModel.cpp +++ b/WindowsAppDataManager/src/qmlmodels/ApplicationListModel.cpp @@ -281,6 +281,11 @@ double ApplicationListModel::maximumSizeValue() const return m_applications.isEmpty() ? 1.0 : static_cast(m_applications.constFirst().totalSize); } +QVector ApplicationListModel::snapshot() const +{ + return m_applications; +} + QVariantMap ApplicationListModel::get(int index) const { if (index < 0 || index >= m_applications.size()) diff --git a/WindowsAppDataManager/src/qmlmodels/ApplicationListModel.h b/WindowsAppDataManager/src/qmlmodels/ApplicationListModel.h index 587dc52..7458f97 100644 --- a/WindowsAppDataManager/src/qmlmodels/ApplicationListModel.h +++ b/WindowsAppDataManager/src/qmlmodels/ApplicationListModel.h @@ -74,6 +74,7 @@ class ApplicationListModel : public QAbstractListModel { [[nodiscard]] int recognizedCount() const; [[nodiscard]] int potentialOrphanCount() const; [[nodiscard]] double maximumSizeValue() const; + [[nodiscard]] QVector snapshot() const; Q_INVOKABLE [[nodiscard]] QVariantMap get(int index) const; Q_INVOKABLE [[nodiscard]] int indexOfId(const QString &applicationId) const; diff --git a/WindowsAppDataManager/src/qmlmodels/ScanViewModel.cpp b/WindowsAppDataManager/src/qmlmodels/ScanViewModel.cpp index 14a9370..bc4821a 100644 --- a/WindowsAppDataManager/src/qmlmodels/ScanViewModel.cpp +++ b/WindowsAppDataManager/src/qmlmodels/ScanViewModel.cpp @@ -1,11 +1,85 @@ #include "ScanViewModel.h" +#include "../services/CleanupPlanBuilder.h" +#include "../services/ScanReportExporter.h" + #include #include +#include +#include #include namespace wam::qmlmodels { +namespace { + +QString issueCodeText(ScanErrorCode code) +{ + switch (code) { + case ScanErrorCode::AccessDenied: + return QStringLiteral("访问被拒绝"); + case ScanErrorCode::PathUnavailable: + return QStringLiteral("路径不可用"); + case ScanErrorCode::IoError: + return QStringLiteral("I/O 错误"); + case ScanErrorCode::Cancelled: + return QStringLiteral("扫描已取消"); + } + return QStringLiteral("未知错误"); +} + +QString formatSize(quint64 bytes) +{ + static constexpr quint64 kibibyte = 1024; + static constexpr quint64 mebibyte = kibibyte * 1024; + static constexpr quint64 gibibyte = mebibyte * 1024; + const QLocale locale; + + if (bytes >= gibibyte) + return locale.toString(static_cast(bytes) / gibibyte, 'f', 1) + QStringLiteral(" GB"); + if (bytes >= mebibyte) + return locale.toString(static_cast(bytes) / mebibyte, 'f', 1) + QStringLiteral(" MB"); + if (bytes >= kibibyte) + return locale.toString(static_cast(bytes) / kibibyte, 'f', 1) + QStringLiteral(" KB"); + return locale.toString(bytes) + QStringLiteral(" B"); +} + +QString categoryText(DataCategory category) +{ + switch (category) { + case DataCategory::Cache: return QStringLiteral("缓存"); + case DataCategory::Log: return QStringLiteral("日志"); + case DataCategory::Temp: return QStringLiteral("临时数据"); + case DataCategory::CrashDump: return QStringLiteral("崩溃报告"); + case DataCategory::Config: return QStringLiteral("配置"); + case DataCategory::Database: return QStringLiteral("数据库"); + case DataCategory::Session: return QStringLiteral("会话数据"); + case DataCategory::Cookie: return QStringLiteral("Cookie"); + case DataCategory::Credential: return QStringLiteral("凭据"); + case DataCategory::UserData: return QStringLiteral("用户数据"); + case DataCategory::Workspace: return QStringLiteral("工作区"); + case DataCategory::SaveGame: return QStringLiteral("存档"); + case DataCategory::DownloadedResource: return QStringLiteral("下载资源"); + case DataCategory::Extension: return QStringLiteral("扩展数据"); + case DataCategory::Unknown: return QStringLiteral("无法判断"); + } + return QStringLiteral("无法判断"); +} + +QString riskText(RiskLevel risk) +{ + switch (risk) { + case RiskLevel::Safe: return QStringLiteral("安全"); + case RiskLevel::Low: return QStringLiteral("低风险"); + case RiskLevel::Caution: return QStringLiteral("需确认"); + case RiskLevel::High: return QStringLiteral("高风险"); + case RiskLevel::Protected: return QStringLiteral("受保护"); + case RiskLevel::Unknown: return QStringLiteral("无法判断"); + } + return QStringLiteral("无法判断"); +} + +} // namespace ScanViewModel::ScanViewModel(ApplicationListModel *applicationModel, QObject *parent) : QObject(parent), @@ -21,8 +95,6 @@ ScanViewModel::ScanViewModel(ApplicationListModel *applicationModel, QObject *pa setProgress(0); setCurrentPath({}); setStatusText(QStringLiteral("正在分析 AppData")); - m_issueCount = 0; - emit issueCountChanged(); clearError(); }); connect(&m_service, &services::ScanService::progressChanged, @@ -34,8 +106,6 @@ ScanViewModel::ScanViewModel(ApplicationListModel *applicationModel, QObject *pa this, [this](const ScanResult &result) { setRunning(false); setCurrentPath({}); - m_issueCount = result.issues.size(); - emit issueCountChanged(); if (result.cancelled) { setProgress(0); setStatusText(QStringLiteral("扫描已取消,保留上一次完整结果")); @@ -43,6 +113,11 @@ ScanViewModel::ScanViewModel(ApplicationListModel *applicationModel, QObject *pa } m_applicationModel->setApplications(result.applications); + clearCleanupPlan(); + m_issues = result.issues; + emit issuesChanged(); + m_issueCount = result.issues.size(); + emit issueCountChanged(); setProgress(100); m_lastScanText = QStringLiteral("今天 %1").arg( QDateTime::currentDateTime().time().toString(QStringLiteral("HH:mm"))); @@ -73,6 +148,22 @@ QString ScanViewModel::technicalDetail() const { return m_technicalDetail; } int ScanViewModel::issueCount() const { return m_issueCount; } bool ScanViewModel::partialResult() const { return m_issueCount > 0; } +QVariantList ScanViewModel::issues() const +{ + QVariantList rows; + rows.reserve(m_issues.size()); + for (const ScanIssue &issue : m_issues) { + QVariantMap row; + row.insert(QStringLiteral("message"), issue.message); + row.insert(QStringLiteral("technicalDetail"), issue.technicalDetail); + row.insert(QStringLiteral("path"), issue.path); + row.insert(QStringLiteral("code"), static_cast(issue.code)); + row.insert(QStringLiteral("codeText"), issueCodeText(issue.code)); + rows.append(row); + } + return rows; +} + void ScanViewModel::toggleScan() { if (m_running) @@ -96,6 +187,49 @@ void ScanViewModel::cancelScan() m_service.cancelScan(); } +QString ScanViewModel::exportReport(const QUrl &destination) const +{ + return services::exportScanReport(destination, m_applicationModel->snapshot(), m_issues); +} + +QVariantList ScanViewModel::cleanupPlan() const +{ + return m_cleanupPlan; +} + +QString ScanViewModel::cleanupPlanTotalText() const +{ + return formatSize(m_cleanupPlanTotalSize); +} + +void ScanViewModel::generateCleanupPlan() +{ + const services::CleanupPlan plan = services::buildCleanupPlan(m_applicationModel->snapshot()); + QVariantList rows; + rows.reserve(plan.items.size()); + for (const services::CleanupPlanItem &item : plan.items) { + QVariantMap row; + row.insert(QStringLiteral("id"), item.id); + row.insert(QStringLiteral("applicationId"), item.applicationId); + row.insert(QStringLiteral("applicationName"), item.applicationName); + row.insert(QStringLiteral("categoryText"), categoryText(item.category)); + row.insert(QStringLiteral("path"), item.path); + row.insert(QStringLiteral("impact"), item.impact); + row.insert(QStringLiteral("ruleSource"), item.ruleSource); + row.insert(QStringLiteral("sizeBytes"), QVariant::fromValue(item.size)); + row.insert(QStringLiteral("sizeText"), formatSize(item.size)); + row.insert(QStringLiteral("fileCount"), QVariant::fromValue(item.fileCount)); + row.insert(QStringLiteral("fileCountText"), QLocale().toString(item.fileCount)); + row.insert(QStringLiteral("riskLevel"), static_cast(item.risk)); + row.insert(QStringLiteral("riskText"), riskText(item.risk)); + rows.append(row); + } + + m_cleanupPlan = std::move(rows); + m_cleanupPlanTotalSize = plan.totalSize; + emit cleanupPlanChanged(); +} + void ScanViewModel::setRunning(bool running) { if (m_running == running) @@ -129,6 +263,15 @@ void ScanViewModel::setStatusText(QString status) emit statusTextChanged(); } +void ScanViewModel::clearCleanupPlan() +{ + if (m_cleanupPlan.isEmpty() && m_cleanupPlanTotalSize == 0) + return; + m_cleanupPlan.clear(); + m_cleanupPlanTotalSize = 0; + emit cleanupPlanChanged(); +} + void ScanViewModel::clearError() { if (m_errorMessage.isEmpty() && m_technicalDetail.isEmpty()) diff --git a/WindowsAppDataManager/src/qmlmodels/ScanViewModel.h b/WindowsAppDataManager/src/qmlmodels/ScanViewModel.h index 296f39e..b275a3e 100644 --- a/WindowsAppDataManager/src/qmlmodels/ScanViewModel.h +++ b/WindowsAppDataManager/src/qmlmodels/ScanViewModel.h @@ -5,6 +5,8 @@ #include #include +#include +#include namespace wam::qmlmodels { @@ -22,6 +24,9 @@ class ScanViewModel : public QObject { Q_PROPERTY(QString technicalDetail READ technicalDetail NOTIFY errorChanged) Q_PROPERTY(int issueCount READ issueCount NOTIFY issueCountChanged) Q_PROPERTY(bool partialResult READ partialResult NOTIFY issueCountChanged) + Q_PROPERTY(QVariantList issues READ issues NOTIFY issuesChanged) + Q_PROPERTY(QVariantList cleanupPlan READ cleanupPlan NOTIFY cleanupPlanChanged) + Q_PROPERTY(QString cleanupPlanTotalText READ cleanupPlanTotalText NOTIFY cleanupPlanChanged) public: explicit ScanViewModel(ApplicationListModel *applicationModel, QObject *parent = nullptr); @@ -36,10 +41,15 @@ class ScanViewModel : public QObject { [[nodiscard]] QString technicalDetail() const; [[nodiscard]] int issueCount() const; [[nodiscard]] bool partialResult() const; + [[nodiscard]] QVariantList issues() const; + [[nodiscard]] QVariantList cleanupPlan() const; + [[nodiscard]] QString cleanupPlanTotalText() const; Q_INVOKABLE void toggleScan(); Q_INVOKABLE void startScan(); Q_INVOKABLE void cancelScan(); + Q_INVOKABLE QString exportReport(const QUrl &destination) const; + Q_INVOKABLE void generateCleanupPlan(); signals: void runningChanged(); @@ -50,12 +60,15 @@ class ScanViewModel : public QObject { void lastScanTextChanged(); void errorChanged(); void issueCountChanged(); + void issuesChanged(); + void cleanupPlanChanged(); private: void setRunning(bool running); void setProgress(int progress); void setCurrentPath(QString path); void setStatusText(QString status); + void clearCleanupPlan(); void clearError(); ApplicationListModel *m_applicationModel = nullptr; @@ -65,6 +78,9 @@ class ScanViewModel : public QObject { QString m_lastScanText = QStringLiteral("尚未扫描"); QString m_errorMessage; QString m_technicalDetail; + QVector m_issues; + QVariantList m_cleanupPlan; + quint64 m_cleanupPlanTotalSize = 0; bool m_running = false; int m_progress = 0; int m_issueCount = 0; diff --git a/WindowsAppDataManager/src/services/CleanupPlanBuilder.cpp b/WindowsAppDataManager/src/services/CleanupPlanBuilder.cpp new file mode 100644 index 0000000..697f7b7 --- /dev/null +++ b/WindowsAppDataManager/src/services/CleanupPlanBuilder.cpp @@ -0,0 +1,51 @@ +#include "CleanupPlanBuilder.h" + +#include + +namespace wam::services { +namespace { + +bool isEligible(const ApplicationInfo &application, const DataGroupInfo &group) +{ + const bool safeRisk = group.risk == RiskLevel::Safe || group.risk == RiskLevel::Low; + return application.installState == InstallState::Installed + && application.confidence >= 70 + && !group.ruleSource.isEmpty() + && group.size > 0 + && group.rebuildable == RebuildableState::Yes + && safeRisk; +} + +} // namespace + +CleanupPlan buildCleanupPlan(const QVector &applications) +{ + CleanupPlan plan; + for (const ApplicationInfo &application : applications) { + for (const DataGroupInfo &group : application.dataGroups) { + if (!isEligible(application, group)) + continue; + + plan.items.append({ + application.id + QLatin1Char(':') + group.id, + application.id, + application.name, + group.category, + group.path, + group.impact, + group.ruleSource, + group.size, + group.fileCount, + group.risk + }); + plan.totalSize += group.size; + } + } + + std::sort(plan.items.begin(), plan.items.end(), [](const auto &left, const auto &right) { + return left.size > right.size; + }); + return plan; +} + +} // namespace wam::services diff --git a/WindowsAppDataManager/src/services/CleanupPlanBuilder.h b/WindowsAppDataManager/src/services/CleanupPlanBuilder.h new file mode 100644 index 0000000..fcd9ad0 --- /dev/null +++ b/WindowsAppDataManager/src/services/CleanupPlanBuilder.h @@ -0,0 +1,30 @@ +#pragma once + +#include "../models/ApplicationInfo.h" + +#include + +namespace wam::services { + +struct CleanupPlanItem { + QString id; + QString applicationId; + QString applicationName; + DataCategory category = DataCategory::Unknown; + QString path; + QString impact; + QString ruleSource; + quint64 size = 0; + quint64 fileCount = 0; + RiskLevel risk = RiskLevel::Unknown; +}; + +struct CleanupPlan { + QVector items; + quint64 totalSize = 0; +}; + +// Builds a preview-only plan. It deliberately excludes unknown, sensitive, and non-rebuildable data. +CleanupPlan buildCleanupPlan(const QVector &applications); + +} // namespace wam::services diff --git a/WindowsAppDataManager/src/services/ScanReportExporter.cpp b/WindowsAppDataManager/src/services/ScanReportExporter.cpp new file mode 100644 index 0000000..81fb4d1 --- /dev/null +++ b/WindowsAppDataManager/src/services/ScanReportExporter.cpp @@ -0,0 +1,332 @@ +#include "ScanReportExporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace wam::services { +namespace { + +QString csvField(QString value) +{ + value.replace(QLatin1Char('"'), QStringLiteral("\"\"")); + return QLatin1Char('"') + value + QLatin1Char('"'); +} + +void writeRow(QTextStream &stream, const QStringList &values) +{ + for (qsizetype index = 0; index < values.size(); ++index) { + if (index > 0) + stream << QLatin1Char(','); + stream << csvField(values.at(index)); + } + stream << Qt::endl; +} + +QString riskText(RiskLevel level) +{ + switch (level) { + case RiskLevel::Safe: return QStringLiteral("安全"); + case RiskLevel::Low: return QStringLiteral("低风险"); + case RiskLevel::Caution: return QStringLiteral("需确认"); + case RiskLevel::High: return QStringLiteral("高风险"); + case RiskLevel::Protected: return QStringLiteral("受保护"); + case RiskLevel::Unknown: return QStringLiteral("未知"); + } + return QStringLiteral("未知"); +} + +QString riskCode(RiskLevel level) +{ + switch (level) { + case RiskLevel::Safe: return QStringLiteral("safe"); + case RiskLevel::Low: return QStringLiteral("low"); + case RiskLevel::Caution: return QStringLiteral("caution"); + case RiskLevel::High: return QStringLiteral("high"); + case RiskLevel::Protected: return QStringLiteral("protected"); + case RiskLevel::Unknown: return QStringLiteral("unknown"); + } + return QStringLiteral("unknown"); +} + +QString categoryCode(DataCategory category) +{ + switch (category) { + case DataCategory::Cache: return QStringLiteral("cache"); + case DataCategory::Log: return QStringLiteral("log"); + case DataCategory::Temp: return QStringLiteral("temp"); + case DataCategory::CrashDump: return QStringLiteral("crash-dump"); + case DataCategory::Config: return QStringLiteral("config"); + case DataCategory::Database: return QStringLiteral("database"); + case DataCategory::Session: return QStringLiteral("session"); + case DataCategory::Cookie: return QStringLiteral("cookie"); + case DataCategory::Credential: return QStringLiteral("credential"); + case DataCategory::UserData: return QStringLiteral("user-data"); + case DataCategory::Workspace: return QStringLiteral("workspace"); + case DataCategory::SaveGame: return QStringLiteral("save-game"); + case DataCategory::DownloadedResource: return QStringLiteral("downloaded-resource"); + case DataCategory::Extension: return QStringLiteral("extension"); + case DataCategory::Unknown: return QStringLiteral("unknown"); + } + return QStringLiteral("unknown"); +} + +QString rebuildableCode(RebuildableState state) +{ + switch (state) { + case RebuildableState::Yes: return QStringLiteral("yes"); + case RebuildableState::No: return QStringLiteral("no"); + case RebuildableState::Unknown: return QStringLiteral("unknown"); + } + return QStringLiteral("unknown"); +} + +QString installStateText(InstallState state) +{ + switch (state) { + case InstallState::Installed: return QStringLiteral("已安装"); + case InstallState::PotentialOrphan: return QStringLiteral("潜在残留"); + case InstallState::Unknown: return QStringLiteral("未识别"); + } + return QStringLiteral("未识别"); +} + +QString installStateCode(InstallState state) +{ + switch (state) { + case InstallState::Installed: return QStringLiteral("installed"); + case InstallState::PotentialOrphan: return QStringLiteral("potential-orphan"); + case InstallState::Unknown: return QStringLiteral("unknown"); + } + return QStringLiteral("unknown"); +} + +QString evidenceSourceCode(EvidenceSource source) +{ + switch (source) { + case EvidenceSource::Registry: return QStringLiteral("registry"); + case EvidenceSource::Appx: return QStringLiteral("appx"); + case EvidenceSource::Executable: return QStringLiteral("executable"); + case EvidenceSource::Publisher: return QStringLiteral("publisher"); + case EvidenceSource::Folder: return QStringLiteral("folder"); + case EvidenceSource::Rule: return QStringLiteral("rule"); + case EvidenceSource::RunningProcess: return QStringLiteral("running-process"); + } + return QStringLiteral("unknown"); +} + +QString evidenceStatusCode(EvidenceStatus status) +{ + switch (status) { + case EvidenceStatus::Matched: return QStringLiteral("matched"); + case EvidenceStatus::Partial: return QStringLiteral("partial"); + case EvidenceStatus::Unavailable: return QStringLiteral("unavailable"); + case EvidenceStatus::Conflict: return QStringLiteral("conflict"); + case EvidenceStatus::NotFound: return QStringLiteral("not-found"); + case EvidenceStatus::Incomplete: return QStringLiteral("incomplete"); + case EvidenceStatus::Ambiguous: return QStringLiteral("ambiguous"); + } + return QStringLiteral("unknown"); +} + +QString issueCodeText(ScanErrorCode code) +{ + switch (code) { + case ScanErrorCode::AccessDenied: return QStringLiteral("访问被拒绝"); + case ScanErrorCode::PathUnavailable: return QStringLiteral("路径不可用"); + case ScanErrorCode::IoError: return QStringLiteral("I/O 错误"); + case ScanErrorCode::Cancelled: return QStringLiteral("扫描已取消"); + } + return QStringLiteral("未知错误"); +} + +QJsonObject applicationJson(const ApplicationInfo &application) +{ + QJsonObject object { + {QStringLiteral("id"), application.id}, + {QStringLiteral("name"), application.name}, + {QStringLiteral("publisher"), application.publisher}, + {QStringLiteral("category"), application.category}, + {QStringLiteral("location"), application.location}, + {QStringLiteral("executablePath"), application.executablePath}, + {QStringLiteral("installPath"), application.installPath}, + {QStringLiteral("totalSizeBytes"), static_cast(application.totalSize)}, + {QStringLiteral("fileCount"), static_cast(application.fileCount)}, + {QStringLiteral("reclaimableBytes"), static_cast(application.reclaimableSize)}, + {QStringLiteral("protectedBytes"), static_cast(application.protectedSize)}, + {QStringLiteral("unknownBytes"), static_cast(application.unknownSize)}, + {QStringLiteral("riskLevel"), static_cast(application.risk)}, + {QStringLiteral("risk"), riskCode(application.risk)}, + {QStringLiteral("riskText"), riskText(application.risk)}, + {QStringLiteral("installStateValue"), static_cast(application.installState)}, + {QStringLiteral("installState"), installStateCode(application.installState)}, + {QStringLiteral("installStateText"), installStateText(application.installState)}, + {QStringLiteral("confidence"), application.confidence}, + {QStringLiteral("lastModified"), application.lastModified.isValid() + ? application.lastModified.toString(Qt::ISODate) : QString()}, + {QStringLiteral("summary"), application.summary} + }; + + QJsonArray groups; + for (const DataGroupInfo &group : application.dataGroups) { + groups.append(QJsonObject { + {QStringLiteral("id"), group.id}, + {QStringLiteral("category"), categoryCode(group.category)}, + {QStringLiteral("categoryValue"), static_cast(group.category)}, + {QStringLiteral("sizeBytes"), static_cast(group.size)}, + {QStringLiteral("fileCount"), static_cast(group.fileCount)}, + {QStringLiteral("riskLevel"), static_cast(group.risk)}, + {QStringLiteral("risk"), riskCode(group.risk)}, + {QStringLiteral("riskText"), riskText(group.risk)}, + {QStringLiteral("rebuildableState"), static_cast(group.rebuildable)}, + {QStringLiteral("rebuildable"), rebuildableCode(group.rebuildable)}, + {QStringLiteral("impact"), group.impact}, + {QStringLiteral("path"), group.path}, + {QStringLiteral("ruleSource"), group.ruleSource} + }); + } + object.insert(QStringLiteral("dataGroups"), groups); + + QJsonArray evidence; + for (const EvidenceInfo &item : application.evidence) { + evidence.append(QJsonObject { + {QStringLiteral("source"), evidenceSourceCode(item.source)}, + {QStringLiteral("sourceValue"), static_cast(item.source)}, + {QStringLiteral("status"), evidenceStatusCode(item.status)}, + {QStringLiteral("statusValue"), static_cast(item.status)}, + {QStringLiteral("detail"), item.detail} + }); + } + object.insert(QStringLiteral("evidence"), evidence); + return object; +} + +QJsonObject issueJson(const ScanIssue &issue) +{ + return { + {QStringLiteral("path"), issue.path}, + {QStringLiteral("message"), issue.message}, + {QStringLiteral("technicalDetail"), issue.technicalDetail}, + {QStringLiteral("code"), static_cast(issue.code)}, + {QStringLiteral("codeText"), issueCodeText(issue.code)} + }; +} + +QString exportJsonReport(const QString &filePath, + const QVector &applications, + const QVector &issues) +{ + quint64 totalSize = 0; + quint64 totalFiles = 0; + quint64 reclaimable = 0; + quint64 protectedSize = 0; + quint64 unknownSize = 0; + QJsonArray applicationRows; + for (const ApplicationInfo &application : applications) { + totalSize += application.totalSize; + totalFiles += application.fileCount; + reclaimable += application.reclaimableSize; + protectedSize += application.protectedSize; + unknownSize += application.unknownSize; + applicationRows.append(applicationJson(application)); + } + + QJsonArray issueRows; + for (const ScanIssue &issue : issues) + issueRows.append(issueJson(issue)); + + const quint64 classified = std::min(totalSize, reclaimable + protectedSize); + const QJsonObject report { + {QStringLiteral("schemaVersion"), QStringLiteral("1.0")}, + {QStringLiteral("reportType"), QStringLiteral("windows-appdata-manager.scan")}, + {QStringLiteral("generatedAt"), QDateTime::currentDateTimeUtc().toString(Qt::ISODate)}, + {QStringLiteral("summary"), QJsonObject { + {QStringLiteral("applicationCount"), applications.size()}, + {QStringLiteral("totalSizeBytes"), static_cast(totalSize)}, + {QStringLiteral("totalFileCount"), static_cast(totalFiles)}, + {QStringLiteral("reclaimableBytes"), static_cast(reclaimable)}, + {QStringLiteral("protectedBytes"), static_cast(protectedSize)}, + {QStringLiteral("unknownBytes"), static_cast(unknownSize)}, + {QStringLiteral("reviewBytes"), static_cast(totalSize - classified)}, + {QStringLiteral("issueCount"), issues.size()} + }}, + {QStringLiteral("applications"), applicationRows}, + {QStringLiteral("issues"), issueRows} + }; + + QSaveFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return QStringLiteral("无法创建报告:%1").arg(file.errorString()); + const QByteArray contents = QJsonDocument(report).toJson(QJsonDocument::Indented); + if (file.write(contents) != contents.size()) + return QStringLiteral("无法写入报告:%1").arg(file.errorString()); + if (!file.commit()) + return QStringLiteral("无法保存报告:%1").arg(file.errorString()); + return {}; +} + +QString exportCsvReport(const QString &filePath, + const QVector &applications, + const QVector &issues) +{ + QSaveFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + return QStringLiteral("无法创建报告:%1").arg(file.errorString()); + + QTextStream stream(&file); + stream.setEncoding(QStringConverter::Utf8); + stream.setGenerateByteOrderMark(true); + + writeRow(stream, {QStringLiteral("记录类型"), QStringLiteral("应用名称"), + QStringLiteral("发布者"), QStringLiteral("分类"), + QStringLiteral("AppData 位置"), QStringLiteral("占用字节"), + QStringLiteral("文件数"), QStringLiteral("风险"), + QStringLiteral("安装状态"), QStringLiteral("摘要")}); + for (const ApplicationInfo &application : applications) { + writeRow(stream, {QStringLiteral("应用"), application.name, application.publisher, + application.category, application.location, + QString::number(application.totalSize), + QString::number(application.fileCount), riskText(application.risk), + installStateText(application.installState), application.summary}); + } + + writeRow(stream, {}); + writeRow(stream, {QStringLiteral("记录类型"), QStringLiteral("位置"), + QStringLiteral("原因"), QStringLiteral("错误类别"), + QStringLiteral("技术详情")}); + for (const ScanIssue &issue : issues) { + writeRow(stream, {QStringLiteral("未完整读取"), issue.path, issue.message, + issueCodeText(issue.code), issue.technicalDetail}); + } + + if (!file.commit()) + return QStringLiteral("无法保存报告:%1").arg(file.errorString()); + return {}; +} + +} // namespace + +QString exportScanReport(const QUrl &destination, + const QVector &applications, + const QVector &issues) +{ + if (!destination.isLocalFile()) + return QStringLiteral("请选择本地文件夹中的导出位置。"); + + const QString filePath = destination.toLocalFile(); + if (filePath.isEmpty()) + return QStringLiteral("导出路径无效。"); + + if (QFileInfo(filePath).suffix().compare(QStringLiteral("json"), Qt::CaseInsensitive) == 0) + return exportJsonReport(filePath, applications, issues); + return exportCsvReport(filePath, applications, issues); +} + +} // namespace wam::services diff --git a/WindowsAppDataManager/src/services/ScanReportExporter.h b/WindowsAppDataManager/src/services/ScanReportExporter.h new file mode 100644 index 0000000..212b477 --- /dev/null +++ b/WindowsAppDataManager/src/services/ScanReportExporter.h @@ -0,0 +1,17 @@ +#pragma once + +#include "../models/ApplicationInfo.h" +#include "../models/ScanResult.h" + +#include +#include +#include + +namespace wam::services { + +// Exports the latest scan as UTF-8 CSV or structured JSON by file suffix. +QString exportScanReport(const QUrl &destination, + const QVector &applications, + const QVector &issues); + +} // namespace wam::services diff --git a/WindowsAppDataManager/tests/tst_backend.cpp b/WindowsAppDataManager/tests/tst_backend.cpp index 23d4a12..190ba39 100644 --- a/WindowsAppDataManager/tests/tst_backend.cpp +++ b/WindowsAppDataManager/tests/tst_backend.cpp @@ -7,12 +7,15 @@ #include "src/qmlmodels/ApplicationFilterModel.h" #include "src/qmlmodels/ApplicationListModel.h" #include "src/qmlmodels/ScanViewModel.h" +#include "src/services/CleanupPlanBuilder.h" +#include "src/services/ScanReportExporter.h" #include #include #include #include #include +#include #include #include @@ -131,6 +134,8 @@ private slots: void applicationListFindsStableIdsAndExposesAccentIndices(); void applicationFilterCombinesSearchAndExactFilters(); void applicationFilterSortsAndMapsSourceRows(); + void scanReportExportsApplicationsAndReadIssues(); + void cleanupPlanOnlyIncludesExplicitSafeRebuildableGroups(); void viewModelPublishesBackgroundScan(); void viewModelAppliesBuiltInRules(); void resolverPublishesApplicationClassificationRules(); @@ -405,10 +410,12 @@ void BackendTest::builtInCatalogContainsMvpApplications() { const auto &catalog = wam::core::rules::RuleCatalog::builtIn(); QVERIFY2(catalog.issues().isEmpty(), "内置规则必须全部通过加载校验"); - QCOMPARE(catalog.applications().size(), 7); + QCOMPARE(catalog.applications().size(), 9); QVERIFY(catalog.findById(QStringLiteral("google-chrome"))); QVERIFY(catalog.findById(QStringLiteral("chromium"))); + QVERIFY(catalog.findById(QStringLiteral("microsoft-edge"))); QVERIFY(catalog.findById(QStringLiteral("discord"))); + QVERIFY(catalog.findById(QStringLiteral("obs-studio"))); QVERIFY(catalog.findById(QStringLiteral("visual-studio-code"))); QVERIFY(catalog.findById(QStringLiteral("jetbrains"))); QVERIFY(catalog.findById(QStringLiteral("windows-temp"))); @@ -924,6 +931,136 @@ void BackendTest::applicationFilterSortsAndMapsSourceRows() } } +void BackendTest::scanReportExportsApplicationsAndReadIssues() +{ + QTemporaryDir temporary; + QVERIFY(temporary.isValid()); + + wam::ApplicationInfo sample = application( + QStringLiteral("sample"), QStringLiteral("示例应用"), + QStringLiteral("示例发布者"), QStringLiteral("工具"), 42, + wam::RiskLevel::Unknown, wam::InstallState::Unknown); + sample.location = QStringLiteral("C:/Users/Example/AppData/Local/Sample"); + sample.fileCount = 3; + sample.summary = QStringLiteral("需要进一步确认。"); + sample.dataGroups = { + {QStringLiteral("cache"), wam::DataCategory::Cache, 42, 3, + wam::RiskLevel::Safe, wam::RebuildableState::Yes, + QStringLiteral("缓存可重新生成。"), sample.location + QStringLiteral("/Cache"), + QStringLiteral("内置规则 / sample@1")} + }; + sample.evidence = { + {wam::EvidenceSource::Registry, wam::EvidenceStatus::Matched, + QStringLiteral("Registry match")} + }; + + wam::ScanIssue issue; + issue.code = wam::ScanErrorCode::AccessDenied; + issue.message = QStringLiteral("无法读取该目录"); + issue.technicalDetail = QStringLiteral("Permission denied"); + issue.path = QStringLiteral("C:/Users/Example/AppData/Local/Private"); + + const QString outputPath = QDir(temporary.path()).filePath(QStringLiteral("report.csv")); + const QString error = wam::services::exportScanReport( + QUrl::fromLocalFile(outputPath), {sample}, {issue}); + QVERIFY2(error.isEmpty(), qPrintable(error)); + + QFile csvReport(outputPath); + QVERIFY2(csvReport.open(QIODevice::ReadOnly), qPrintable(csvReport.errorString())); + const QString contents = QString::fromUtf8(csvReport.readAll()); + QVERIFY(contents.contains(QStringLiteral("示例应用"))); + QVERIFY(contents.contains(QStringLiteral("未完整读取"))); + QVERIFY(contents.contains(QStringLiteral("访问被拒绝"))); + + const QString jsonPath = QDir(temporary.path()).filePath(QStringLiteral("report.json")); + const QString jsonError = wam::services::exportScanReport( + QUrl::fromLocalFile(jsonPath), {sample}, {issue}); + QVERIFY2(jsonError.isEmpty(), qPrintable(jsonError)); + + QFile jsonReport(jsonPath); + QVERIFY2(jsonReport.open(QIODevice::ReadOnly), qPrintable(jsonReport.errorString())); + QJsonParseError parseError; + const QJsonDocument json = QJsonDocument::fromJson(jsonReport.readAll(), &parseError); + QCOMPARE(parseError.error, QJsonParseError::NoError); + QVERIFY(json.isObject()); + const QJsonObject jsonReportObject = json.object(); + QCOMPARE(jsonReportObject.value(QStringLiteral("schemaVersion")).toString(), QStringLiteral("1.0")); + QCOMPARE(jsonReportObject.value(QStringLiteral("reportType")).toString(), + QStringLiteral("windows-appdata-manager.scan")); + QCOMPARE(jsonReportObject.value(QStringLiteral("summary")).toObject() + .value(QStringLiteral("applicationCount")).toInt(), 1); + const QJsonArray exportedApplications = jsonReportObject.value(QStringLiteral("applications")).toArray(); + QCOMPARE(exportedApplications.size(), 1); + QCOMPARE(exportedApplications.at(0).toObject() + .value(QStringLiteral("name")).toString(), QStringLiteral("示例应用")); + const QJsonObject exportedApplication = exportedApplications.at(0).toObject(); + QCOMPARE(exportedApplication.value(QStringLiteral("risk")).toString(), QStringLiteral("unknown")); + QCOMPARE(exportedApplication.value(QStringLiteral("installState")).toString(), + QStringLiteral("unknown")); + const QJsonArray exportedGroups = exportedApplication.value(QStringLiteral("dataGroups")).toArray(); + QCOMPARE(exportedGroups.size(), 1); + const QJsonObject exportedGroup = exportedGroups.at(0).toObject(); + QCOMPARE(exportedGroup.value(QStringLiteral("category")).toString(), QStringLiteral("cache")); + QCOMPARE(exportedGroup.value(QStringLiteral("risk")).toString(), QStringLiteral("safe")); + QCOMPARE(exportedGroup.value(QStringLiteral("rebuildable")).toString(), QStringLiteral("yes")); + const QJsonArray exportedEvidenceRows = exportedApplication.value(QStringLiteral("evidence")).toArray(); + QCOMPARE(exportedEvidenceRows.size(), 1); + const QJsonObject exportedEvidence = exportedEvidenceRows.at(0).toObject(); + QCOMPARE(exportedEvidence.value(QStringLiteral("source")).toString(), QStringLiteral("registry")); + QCOMPARE(exportedEvidence.value(QStringLiteral("status")).toString(), QStringLiteral("matched")); + const QJsonArray exportedIssues = jsonReportObject.value(QStringLiteral("issues")).toArray(); + QCOMPARE(exportedIssues.size(), 1); + QCOMPARE(exportedIssues.at(0).toObject() + .value(QStringLiteral("codeText")).toString(), QStringLiteral("访问被拒绝")); +} + +void BackendTest::cleanupPlanOnlyIncludesExplicitSafeRebuildableGroups() +{ + wam::ApplicationInfo identified = application( + QStringLiteral("identified"), QStringLiteral("已识别应用"), + QStringLiteral("示例发布者"), QStringLiteral("工具"), 125, + wam::RiskLevel::Safe, wam::InstallState::Installed); + identified.confidence = 92; + identified.dataGroups = { + {QStringLiteral("cache"), wam::DataCategory::Cache, 100, 4, + wam::RiskLevel::Safe, wam::RebuildableState::Yes, + QStringLiteral("缓存可重新生成。"), QStringLiteral("C:/Cache"), + QStringLiteral("内置规则 / sample@1")}, + {QStringLiteral("log"), wam::DataCategory::Log, 25, 2, + wam::RiskLevel::Low, wam::RebuildableState::Yes, + QStringLiteral("日志可重新生成。"), QStringLiteral("C:/Logs"), + QStringLiteral("内置规则 / sample@1")}, + {QStringLiteral("credential"), wam::DataCategory::Credential, 50, 1, + wam::RiskLevel::Protected, wam::RebuildableState::No, + QStringLiteral("不能纳入计划。"), QStringLiteral("C:/Credential"), + QStringLiteral("内置规则 / sample@1")}, + {QStringLiteral("unproven"), wam::DataCategory::Cache, 10, 1, + wam::RiskLevel::Safe, wam::RebuildableState::Yes, + QStringLiteral("缺少规则来源。"), QStringLiteral("C:/Unproven"), {}} + }; + + wam::ApplicationInfo uncertain = application( + QStringLiteral("uncertain"), QStringLiteral("未确认应用"), + {}, QStringLiteral("工具"), 80, + wam::RiskLevel::Unknown, wam::InstallState::Unknown); + uncertain.confidence = 72; + uncertain.dataGroups = { + {QStringLiteral("cache"), wam::DataCategory::Cache, 80, 1, + wam::RiskLevel::Safe, wam::RebuildableState::Yes, + QStringLiteral("不应纳入计划。"), QStringLiteral("C:/Unknown"), + QStringLiteral("内置规则 / sample@1")} + }; + + const wam::services::CleanupPlan plan = + wam::services::buildCleanupPlan({identified, uncertain}); + QCOMPARE(plan.items.size(), 2); + QCOMPARE(plan.totalSize, 125ULL); + QCOMPARE(plan.items.at(0).id, QStringLiteral("identified:cache")); + QCOMPARE(plan.items.at(0).size, 100ULL); + QCOMPARE(plan.items.at(1).id, QStringLiteral("identified:log")); + QCOMPARE(plan.items.at(1).size, 25ULL); +} + void BackendTest::viewModelPublishesBackgroundScan() { QTemporaryDir temporary;