Skip to content
Draft
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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The app can scan this Mac directly or import an existing raw JSON report. Proces

### Current source features

The features below are included in v0.1.2 and the current source on `main`, including collection health, the comparison dashboard, snapshots, review summaries, and diagnostic-log explanations.
This section describes the current source checkout, not a guarantee about the downloaded release. The published v0.1.2 app is an earlier build: pending comparison/raw-evidence improvements, optional plain-English explanations, PDF/export corrections, and Camera field explanations are not included in that download. Until merged, these changes are available only in development checkouts and review branches; consult the release notes for the features shipped in each download.

- All supported `system_profiler` data types represented by the app’s current macOS toolchain
- A sidebar with eight subjects (Overview, Hardware, Storage, Network, Software, Security, Power, and Reports) and the What Changed? tool
Expand Down Expand Up @@ -229,12 +229,28 @@ Choose **Share › Report File** to save the scan data. The export review offers
- **Redacted report** replaces every reported scalar and record name, omits exact collection times, and removes standard-error text.
- **Full private report** preserves exact values and metadata for private analysis and future comparison.

A current scan can be compared with a compatible full report created from the same subject or full-report scope. Named records are matched independently of order; unnamed records and array elements are matched by position. Added, removed, and changed values identify structured differences, not their cause or security impact.
A current scan can be compared with a compatible full report created from the same subject or full-report scope, with each data type appearing only once. Top-level records and nested lists containing only object records use original unique names to match independently of order. Duplicate-name and unnamed records are matched by identical complete contents, preserving duplicate counts; unmatched records on both sides of an ambiguous group stop the comparison with an explicit error and affected nested locations. A sole unnamed top-level section container, scalar lists, and mixed-type lists remain positional. Baseline and current source locations retain each report's actual top-level and nested indices, even when matched records move. Added findings have only a current location; removed findings have only a baseline location. The comparison sheet's search includes both locations. Added, removed, and changed values identify structured differences, not their cause or security impact.

Compare in **What Changed?**, under Tools in the sidebar. Pick a saved snapshot of the same report (or compare with the latest in one click), or choose a file: a full export from this app or raw `system_profiler -json` output. It then presents clear added, removed, and changed totals. Differences are grouped as **Review First**, **Worth Reviewing**, or **Informational** to guide triage—not to assign a threat level. Expand a difference for its baseline/current values, raw source location, and a plain-language explanation grounded in the app’s explanation catalog.
If either report's known command scope includes a data type that was never returned, comparison stops with the affected baseline/current data types and collection labels. Recollect missing sections or choose complete reports of the same scope; incomplete collection is not an addition/removal or a successful “no changes” result. A section that was explicitly returned with no records remains comparable. Raw JSON imports lack their original collection scope, so the app compares only their present sections without inventing missing-collection history.

Compare in **What Changed?**, under Tools in the sidebar. Pick a saved snapshot of the same report (or compare with the latest in one click), or choose a file: a full export from this app or raw `system_profiler -json` output. It then presents clear added, removed, and changed totals. Differences are grouped as **Review First**, **Worth Reviewing**, or **Informational** to guide triage—not to assign a threat level. Expand a difference for its baseline/current values, source locations, and a plain-language explanation grounded in the app’s explanation catalog. Choose **Review Details** to open the searchable comparison sheet with added/removed/changed filters. **View baseline raw evidence** and **View current raw evidence** resolve exact typed field/index addresses against the two reports retained for that comparison, including literal keys containing dots or brackets. The read-only viewer shows the selected original structured value with reconstructed JSON formatting and bounded pages—not original file bytes. Absent sides have no evidence button. Raw evidence is not sent to Apple Intelligence or persisted by the viewer.

For a readable document, choose **Share › Summary**. The app exports Markdown or PDF with At a glance, the values worth a look, the full explanations of any bookmarked findings, collection limits (including skipped sections), and an explicit privacy warning. It never folds raw JSON into this summary; use **Share › Report File** when raw evidence is required.

### Optional plain-English field guide (prototype)

Expand **About this field**, then choose **Explain in plain English** for supported hardware fields (chip, model name/identifier, installed memory, processor configuration, Boot ROM, OS loader) or battery cycle count. Apple Intelligence rewords only the app’s maintained, value-independent field description on this Mac. Reported values, identifiers, names, logs, and snapshots are not model input. Unknown/private/value-dependent fields are deliberately outside this prototype.

This requires macOS 26 or later and an available Apple Intelligence on-device model with English support. Unsupported Macs, disabled Intelligence, and models that are not ready get an explicit reason; the app’s existing collection, comparison, and maintained explanations continue to work without it. There is no cloud fallback, automatic retry, or settings change. Closing the sheet cancels its request; a 30-second deadline also requests cancellation, subject to the native model honoring it.

If unavailable, **Check availability** explicitly checks again; it does not enable Intelligence or download a model. A model becoming unavailable during a request keeps its specific reason. The app still supports macOS 13 and later, including Intel Macs; the optional framework is weak-linked and used only behind macOS 26 availability checks.

Generated prose is visibly labeled, may be inaccurate, and explains the field—not the reported value or the cause of a change. It never replaces curated text, changes finding status, or enters reports, snapshots, or exports. Review it against the maintained explanation and raw source. Developers can opt into the real native availability/generation check using `TEST_RUNNER_SYSTEM_PROFILER_NATIVE_EXPLANATION_TEST=1` with the native test command; an unavailable model verifies refusal, not successful generation.

To verify a build with the on-device integration completely excluded, add `SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) SYSTEM_PROFILER_WITHOUT_ON_DEVICE_MODEL'` to the native build/test command and use a separate derived-data directory. This build makes no FoundationModels requests and shows an explicit build-without-framework reason. Testing it on a newer Mac is not a substitute for running the normal build on older macOS or physical Intel hardware.

Use the [older Intel Mac compatibility test guide](docs/compatibility-testing.md) for candidate verification, synthetic import/comparison fixtures, unavailable-state checks, safe snapshot isolation, and a results checklist. Xcode is not required to run the prebuilt test candidate.

## Build from source

Requirements:
Expand Down
28 changes: 28 additions & 0 deletions SystemProfilerExplorer.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
objects = {

/* Begin PBXBuildFile section */
F1E1D2A301004AB0910A0001 /* ComparisonRawEvidence.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E1D2A301004AB0910B0001 /* ComparisonRawEvidence.swift */; };
F1E1D2A301004AB0910A0002 /* ComparisonRawEvidenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E1D2A301004AB0910B0002 /* ComparisonRawEvidenceView.swift */; };
F1E1D2A301004AB0910A0003 /* ComparisonRawEvidenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E1D2A301004AB0910B0003 /* ComparisonRawEvidenceTests.swift */; };
F1E1D2A301004AB0900A0003 /* OnDevicePlainEnglish.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E1D2A301004AB0900B0003 /* OnDevicePlainEnglish.swift */; };
F1E1D2A301004AB0900A0004 /* PlainEnglishExplanationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E1D2A301004AB0900B0004 /* PlainEnglishExplanationView.swift */; };
F1E1D2A301004AB0900A0001 /* PlainEnglishExplanation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E1D2A301004AB0900B0001 /* PlainEnglishExplanation.swift */; };
F1E1D2A301004AB0900A0002 /* PlainEnglishExplanationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1E1D2A301004AB0900B0002 /* PlainEnglishExplanationTests.swift */; };
C35C203F766D456799E8BF1C /* AboutAppView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A235CD75E0DA42F5BCFF5847 /* AboutAppView.swift */; };
007AF890AD2A2E150FC2FF77 /* PowerExplanationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF48C92BB49A1D7C2BF3D06 /* PowerExplanationTests.swift */; };
06011960E1B3DC38DFF2638C /* SecureElementAndSmartCardExplanations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 433F05B4C4BA2EEF1C3E65B0 /* SecureElementAndSmartCardExplanations.swift */; };
Expand Down Expand Up @@ -120,6 +127,13 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
F1E1D2A301004AB0910B0001 /* ComparisonRawEvidence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparisonRawEvidence.swift; sourceTree = "<group>"; };
F1E1D2A301004AB0910B0002 /* ComparisonRawEvidenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparisonRawEvidenceView.swift; sourceTree = "<group>"; };
F1E1D2A301004AB0910B0003 /* ComparisonRawEvidenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComparisonRawEvidenceTests.swift; sourceTree = "<group>"; };
F1E1D2A301004AB0900B0003 /* OnDevicePlainEnglish.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnDevicePlainEnglish.swift; sourceTree = "<group>"; };
F1E1D2A301004AB0900B0004 /* PlainEnglishExplanationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainEnglishExplanationView.swift; sourceTree = "<group>"; };
F1E1D2A301004AB0900B0001 /* PlainEnglishExplanation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainEnglishExplanation.swift; sourceTree = "<group>"; };
F1E1D2A301004AB0900B0002 /* PlainEnglishExplanationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlainEnglishExplanationTests.swift; sourceTree = "<group>"; };
A235CD75E0DA42F5BCFF5847 /* AboutAppView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutAppView.swift; sourceTree = "<group>"; };
01565FC67C66F8D63C14F3FD /* SystemProfilerExplorerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemProfilerExplorerApp.swift; sourceTree = "<group>"; };
04216053FB73986A00A03A57 /* ReportComparison.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportComparison.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -293,6 +307,8 @@
387CBF80EA9183D8E5CB8111 /* SystemProfilerExplorerTests */ = {
isa = PBXGroup;
children = (
F1E1D2A301004AB0910B0003 /* ComparisonRawEvidenceTests.swift */,
F1E1D2A301004AB0900B0002 /* PlainEnglishExplanationTests.swift */,
A841B78A28F111C12390310A /* ValueCatalogTests.swift */,
C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */,
5E4302147D18912C4D6C9E8F /* AdditionalExplanationCoverageTests.swift */,
Expand Down Expand Up @@ -387,6 +403,7 @@
84BB818C260D375773C51F1F /* Comparison */ = {
isa = PBXGroup;
children = (
F1E1D2A301004AB0910B0001 /* ComparisonRawEvidence.swift */,
6460BE296C1F7F744F9F12D3 /* ReportChangeInsight.swift */,
04216053FB73986A00A03A57 /* ReportComparison.swift */,
);
Expand Down Expand Up @@ -422,6 +439,8 @@
8D88B38D8A885854AFCD1A21 /* Explanations */ = {
isa = PBXGroup;
children = (
F1E1D2A301004AB0900B0003 /* OnDevicePlainEnglish.swift */,
F1E1D2A301004AB0900B0001 /* PlainEnglishExplanation.swift */,
D67584376B2445EF1DF746DE /* AccessibilityExplanations.swift */,
895BF724C185EB42ECC0960C /* AdditionalDataTypeExplanations.swift */,
78AF3F713C4A791BA1C636FA /* ContextualFieldExplanations.swift */,
Expand Down Expand Up @@ -463,6 +482,7 @@
A1F533BE0E9ECB4C82C46B37 /* Scan */ = {
isa = PBXGroup;
children = (
F1E1D2A301004AB0900B0004 /* PlainEnglishExplanationView.swift */,
FCB5B24DFE76BB7DE34B6878 /* DiagnosticLogView.swift */,
ADBD375703B00F7FFCFA2A47 /* ProfileReportView.swift */,
4A51AB239BF7150A0E3357EE /* ScanState.swift */,
Expand Down Expand Up @@ -491,6 +511,7 @@
BF685168274C6F83C67D2CED /* Comparison */ = {
isa = PBXGroup;
children = (
F1E1D2A301004AB0910B0002 /* ComparisonRawEvidenceView.swift */,
C19D421A0C7E053E97CA1F4B /* ReportComparisonView.swift */,
);
path = Comparison;
Expand Down Expand Up @@ -611,6 +632,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F1E1D2A301004AB0910A0003 /* ComparisonRawEvidenceTests.swift in Sources */,
F1E1D2A301004AB0900A0002 /* PlainEnglishExplanationTests.swift in Sources */,
B89D0EC021ABEEDFA1DE36B0 /* ValueCatalogTests.swift in Sources */,
B2C53C0C964281379BF8AEB3 /* ValuePanelTests.swift in Sources */,
41A352BEAE8240924801481C /* AdditionalExplanationCoverageTests.swift in Sources */,
Expand Down Expand Up @@ -646,6 +669,11 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
F1E1D2A301004AB0910A0001 /* ComparisonRawEvidence.swift in Sources */,
F1E1D2A301004AB0910A0002 /* ComparisonRawEvidenceView.swift in Sources */,
F1E1D2A301004AB0900A0003 /* OnDevicePlainEnglish.swift in Sources */,
F1E1D2A301004AB0900A0004 /* PlainEnglishExplanationView.swift in Sources */,
F1E1D2A301004AB0900A0001 /* PlainEnglishExplanation.swift in Sources */,
C35C203F766D456799E8BF1C /* AboutAppView.swift in Sources */,
CD175DDDEB38931B4B1ECC72 /* ExtensionValueRules.swift in Sources */,
D0EB1B42212523FE20AEC875 /* ValuePanel.swift in Sources */,
Expand Down
92 changes: 92 additions & 0 deletions SystemProfilerExplorer/Core/Comparison/ComparisonRawEvidence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Foundation

enum ComparisonEvidenceSide: String, Sendable, Identifiable {
case baseline
case current

var id: String { rawValue }

var title: String {
switch self {
case .baseline: "Baseline"
case .current: "Current"
}
}
}

struct ComparisonRawEvidence: Sendable, Equatable {
let side: ComparisonEvidenceSide
let address: ProfileValueAddress
let value: ProfileValue
let completedAt: Date
}

enum ComparisonRawEvidenceError: LocalizedError, Equatable {
case missingSide(ComparisonEvidenceSide)
case valueMismatch

var errorDescription: String? {
switch self {
case let .missingSide(side):
"This finding has no \(side.title.lowercased()) evidence. Added findings exist only in the current report; removed findings exist only in the baseline."
case .valueMismatch:
"The addressed value does not match this comparison's reported value. No substitute evidence was shown. Close this viewer and create a new comparison."
}
}
}

/// Uses the original in-memory source for this side, never a later scan or file read.
/// Resolution and value validation fail explicitly rather than searching by a display
/// path, substituting the other side, or confusing a reported null with missing data.
func comparisonRawEvidence(
for change: ReportChange,
side: ComparisonEvidenceSide,
comparison: ReportComparison
) throws -> ComparisonRawEvidence {
guard comparison.changes.contains(change) else {
throw ComparisonRawEvidenceError.valueMismatch
}
let report: SystemProfilerReport
let address: ProfileValueAddress?
let expectedValue: ProfileScalar?
switch side {
case .baseline:
report = comparison.baselineReport
address = change.baselineAddress
expectedValue = change.previousValue
case .current:
report = comparison.currentReport
address = change.currentAddress
expectedValue = change.currentValue
}

guard let address, let expectedValue else {
throw ComparisonRawEvidenceError.missingSide(side)
}
let value: ProfileValue = try profileValue(at: address, in: report)
guard value == rawProfileValue(expectedValue) else {
throw ComparisonRawEvidenceError.valueMismatch
}
return ComparisonRawEvidence(side: side, address: address, value: value, completedAt: report.completedAt)
}

private func rawProfileValue(_ scalar: ProfileScalar) -> ProfileValue {
switch scalar {
case let .string(value): .string(value)
case let .integer(value): .integer(value)
case let .decimal(value): .decimal(value)
case let .boolean(value): .boolean(value)
case .null: .null
}
}

/// Reconstructed JSON for the selected structured value, not original file bytes.
/// Pages use the existing bounded, cancellation-aware text pagination and never
/// normalize, redact, or truncate the retained value.
func comparisonRawEvidencePages(_ evidence: ComparisonRawEvidence) throws -> [String] {
try Task.checkCancellation()
let encoder: JSONEncoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
let data: Data = try encoder.encode(evidence.value)
return try diagnosticLogPages(String(decoding: data, as: UTF8.self))
}
Loading
Loading