From a9510b139c938d7387805eb2eaf78fa1c6958792 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:38:15 +0000 Subject: [PATCH 01/15] Plan a value-first explanation panel and full value coverage Expanded rows lead with a large field description that repeats on every row with the same field. The plan moves it into one small collapsed area, puts what the specific value means first, and lists the steps for explaining every value of fields with a limited set of values. --- PLAN.md | 145 +++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 101 insertions(+), 44 deletions(-) diff --git a/PLAN.md b/PLAN.md index 979d1cd..692611a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,53 +1,110 @@ -# Plan: value-aware explanations for common enumeration fields +# Plan: lead with the value, and explain every value of limited-set fields -`docs/value-inventory.md` was generated before value rules existed, so its -"value-aware" column (2 of 526 fields) is out of date. Comparing its -enumeration and on/off fields with the rules in -`SystemProfilerExplorer/Core/Explanations/Values` shows about 100 fields that -still get no value explanation. PR #8 covers field explanations only, so none -of these overlap with it. +This branch builds on `value-aware-explanations` (PR #9), which added value +rules for about 60 fields. It changes what an expanded row shows, and then +gives every value of every limited-set field a full explanation. -This branch adds value rules for the ones that matter most, in small steps. -Each step adds rules, tests for every value observed in the inventory, and -tests for the values that change the status. +## The problem -Rules only go on fields whose values don't identify a person or a Mac, -because anonymized samples keep the values of fields that have a rule. +Expanding a row such as Applications › SystemIntents › Supported +Architecture = `arch_arm` shows a large "About this field" block: What it +means, Why it matters, Interpret carefully. That text describes the field +`arch_kind`, not the value. It is identical on all 769 application rows, the +field name is already on the left, and the value is already on the right. So +the biggest thing in the panel says nothing about the result the user +clicked, and the value's own explanation is at most one line above it. + +**Is "About this field" redundant?** At full size on every row, yes. The text +itself is still useful: the first time someone meets a field, and as the only +explanation for names, numbers and identifiers, where there is nothing +value-specific to say. So it stays, but once, small, and collapsed. + +## New layout of an expanded row + +1. **Row (unchanged):** field name on the left, value on the right, then the + status badge and a one-line summary of what this value means. +2. **Value panel** (tinted by the value's status), in this order: + - **What this result means**: what this specific value says about this Mac. + - **Why it matters**: what this value changes for the user. + - **Why the app thinks so**: only for inferred explanations, listing the clues. + - **What to check**: what to look at or do next ("Nothing to do" when normal). + - A small footer line: **Source:** Documented by Apple, Standard macOS + behavior, or Inferred by the app. +3. **About this field**: a single caption-sized disclosure line with an info + icon, collapsed by default. It holds the field-level text (what the field + records, why it matters, interpret carefully, privacy), the explanation + coverage note that used to appear as "Curated explanation", and in + Developer mode the source path and "Show Raw Source Location". It starts + open only when the value has no explanation of its own (names, numbers, + identifiers, free text), because then it is the only explanation. +4. **Terms used here** (unchanged). + +Removed: the Developer-mode "Curated explanation" badge under every field +name, and the coverage box at the top of the panel. Both now live in the +About this field area. + +A value the app doesn't recognize for a limited-set field shows the status +"Not yet explained" and says so plainly. The app never guesses. + +## What each value explanation has + +- a short plain summary (the row line) +- what it means on this Mac +- why it matters +- what to check or do +- a status: Normal, Info, Worth a look (or Not yet explained) +- its source: documented by Apple, standard macOS behavior, or inferred + (with the reasons shown) + +Sources and spelling status for every value are listed in +`docs/value-explanations.md`. A spelling is **confirmed** when it appears in +`docs/value-inventory.md`, in Apple's own System Information localization +strings, or in system_profiler output published online; otherwise it is +marked **unconfirmed**. ## Steps -1. [x] Write this plan. -2. [x] Startup security (Apple Bridge): Secure Boot level, System Integrity - Protection, Signed System Volume, kernel CTRR, boot-argument filtering, - third-party kernel extensions, and privileged MDM operations. -3. [x] Proxy settings (Network and Network Locations): each proxy switch, - automatic proxy configuration and discovery, passive FTP, simple host - names, and VPN On Demand. -4. [x] Power: Low Power Mode, High Power Mode, network reachability during - sleep, reduced brightness on battery, power adapter connected, UPS, and - scheduled power event types. -5. [x] Storage: internal or external disk, connection protocol, ignored - ownership, and NVMe removable and detachable media. -6. [x] Software and Secure Element: font format and flags, private - frameworks, extension loadability and architectures, Secure Element - restricted mode and production signing. -7. [x] Network locations and connection settings: active location, Wi-Fi - join mode, VPN sign-in method, when PPP and VPN connections end, the - dial-up switches, Ethernet media subtype, and automounted network - volumes. -8. [x] Hardware states: display online, Bluetooth controller transport, - Wi-Fi regulatory locale, USB hardware type, memory type, card reader - link, and managed preference state. Display contrast is left out: its - values were withheld from the inventory, so their format is unknown. -9. [x] Note in `docs/value-inventory.md` that value rules now exist and - which enumeration fields are still unexplained. - -## Verification - -This environment has no Xcode, so each pushed commit is verified by the CI -`build-and-test` job. Nothing here needs a live scan, but the owner's Mac is -the only way to confirm the exact spellings macOS uses for values that did -not appear in the inventory (for example `Reduced Security`). +Each step is one commit with its tests, and runs the publication-boundary +check. There is no Xcode here, so CI's `build-and-test` job is the build and +test run. + +1. [ ] Write this plan. +2. [ ] Layout: add "why it matters" to value explanations, build the + value-first panel and the collapsed About this field area, move the + coverage note into it, and include the new parts in Copy as Markdown. +3. [ ] Applications and frameworks (most rows in a scan): architecture + (`arch_kind`), where it came from (`obtained_from`), and private + frameworks. +4. [ ] Fonts (the most values in a scan): font kind, enabled, valid, + duplicate, copy protected, embeddable, outline. +5. [ ] Extensions: loaded, loadable, dependencies, Intel code, + architectures. +6. [ ] Network services and locations: hardware, service type, IPv4 and IPv6 + configuration, proxies, VPN On Demand and its rules, PPP and VPN + switches, Wi-Fi join mode, VPN sign-in, Ethernet media, active location, + network volumes. +7. [ ] Install history, legacy software, and firewall. +8. [ ] Wi-Fi: status, security, network type, capabilities, regulatory + locale. +9. [ ] Power and battery: battery condition and charge states, power + source, hibernate mode, Low and High Power Mode, network reachability, + reduced brightness, adapter, UPS, scheduled events. +10. [ ] Storage and NVMe: SMART, file system, medium, partition map, + writable, internal, protocol, ownership, TRIM, removable, detachable, + volume content. +11. [ ] Startup security, software overview and hardware: Secure Boot and + its protections, SIP, secure virtual memory, boot mode, Activation Lock. +12. [ ] Displays, audio, Bluetooth, Thunderbolt, USB, memory, card readers. +13. [ ] Settings and profiles: accessibility, language and region, + configuration profiles, managed preferences, printers, sync services, + Secure Element. +14. [ ] Update `docs/value-inventory.md` to point at the new list, and + finish the scan list below. + +## Values that need a scan on a real Mac + +To be completed as the steps land. These are spellings that no public +source confirms, or fields whose values the inventory withheld. ## Blocked or deferred From 13d135b94f48a2de8a7206c767afd061023398eb Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:40:25 +0000 Subject: [PATCH 02/15] Lead expanded rows with what the value means An expanded row now starts with a panel about the value itself: what this result means, why it matters, the clues behind an inference, what to check, and where the explanation comes from. The field-level text, which is the same on every row with that field, moves into one small About this field area that starts collapsed, together with the explanation coverage note that used to sit under every field name. It opens by itself only when the value has no explanation of its own. Value explanations gain a why-it-matters part, unrecognized values say plainly that they aren't explained yet, and Copy as Markdown includes the new parts. --- PLAN.md | 4 +- .../project.pbxproj | 8 + .../Values/SystemValueRules.swift | 5 +- .../Values/ValueExplanation.swift | 67 +++++- .../Core/Export/FindingMarkdown.swift | 14 +- .../Core/Presentation/ValuePanel.swift | 77 ++++++ .../Features/Scan/ProfileReportView.swift | 227 +----------------- .../Features/Scan/ValueExplanationViews.swift | 219 ++++++++++++----- .../ValueExplanationTests.swift | 5 +- .../ValuePanelTests.swift | 88 +++++++ 10 files changed, 425 insertions(+), 289 deletions(-) create mode 100644 SystemProfilerExplorer/Core/Presentation/ValuePanel.swift create mode 100644 SystemProfilerExplorerTests/ValuePanelTests.swift diff --git a/PLAN.md b/PLAN.md index 692611a..ecb5dea 100644 --- a/PLAN.md +++ b/PLAN.md @@ -68,8 +68,8 @@ Each step is one commit with its tests, and runs the publication-boundary check. There is no Xcode here, so CI's `build-and-test` job is the build and test run. -1. [ ] Write this plan. -2. [ ] Layout: add "why it matters" to value explanations, build the +1. [x] Write this plan. +2. [x] Layout: add "why it matters" to value explanations, build the value-first panel and the collapsed About this field area, move the coverage note into it, and include the new parts in Copy as Markdown. 3. [ ] Applications and frameworks (most rows in a scan): architecture diff --git a/SystemProfilerExplorer.xcodeproj/project.pbxproj b/SystemProfilerExplorer.xcodeproj/project.pbxproj index 68b0dc3..4dd4ae5 100644 --- a/SystemProfilerExplorer.xcodeproj/project.pbxproj +++ b/SystemProfilerExplorer.xcodeproj/project.pbxproj @@ -102,6 +102,8 @@ FCC946A1C70F7FF865AD3B8C /* NetworkLocationExplanations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6631DF91B1CCB392AB60CE1B /* NetworkLocationExplanations.swift */; }; FDFE36FE6265328C82D9B3F9 /* ProfileValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E59FC08E72AC521D7F47C2 /* ProfileValue.swift */; }; FE864288671D00433353FBB2 /* CollectionCoverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949093927A321A77B7F5D829 /* CollectionCoverageTests.swift */; }; + D0EB1B42212523FE20AEC875 /* ValuePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 782B45221FBF672C566ABAC4 /* ValuePanel.swift */; }; + B2C53C0C964281379BF8AEB3 /* ValuePanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -212,6 +214,8 @@ FC3A92239C2A73BC1B542323 /* FindingLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindingLocationTests.swift; sourceTree = ""; }; 83C8CA9CCE8BBA4E67E5A65D /* SampleFixtureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleFixtureTests.swift; sourceTree = ""; }; FCB5B24DFE76BB7DE34B6878 /* DiagnosticLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLogView.swift; sourceTree = ""; }; + 782B45221FBF672C566ABAC4 /* ValuePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePanel.swift; sourceTree = ""; }; + C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePanelTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -281,6 +285,7 @@ 387CBF80EA9183D8E5CB8111 /* SystemProfilerExplorerTests */ = { isa = PBXGroup; children = ( + C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */, 5E4302147D18912C4D6C9E8F /* AdditionalExplanationCoverageTests.swift */, D71F24CE50090E539F32B351 /* ChangeInsightTests.swift */, 949093927A321A77B7F5D829 /* CollectionCoverageTests.swift */, @@ -314,6 +319,7 @@ 3AB2827A2125644BA53D5C15 /* Presentation */ = { isa = PBXGroup; children = ( + 782B45221FBF672C566ABAC4 /* ValuePanel.swift */, 54856EED5233EA2B07614DAC /* CollectionAttemptHealth.swift */, 813E23B10C1353CEB0EDBEA5 /* CollectionCoverage.swift */, 0C939FCB80491F6CD18861FC /* DiagnosticLogSummary.swift */, @@ -596,6 +602,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B2C53C0C964281379BF8AEB3 /* ValuePanelTests.swift in Sources */, 41A352BEAE8240924801481C /* AdditionalExplanationCoverageTests.swift in Sources */, 8D9B09B8B72B1F0B2F5873B1 /* ChangeInsightTests.swift in Sources */, FE864288671D00433353FBB2 /* CollectionCoverageTests.swift in Sources */, @@ -629,6 +636,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D0EB1B42212523FE20AEC875 /* ValuePanel.swift in Sources */, 329EAAA4FAD74DC9148A730D /* AccessibilityExplanations.swift in Sources */, 1225F9FD5B4A1D5480726182 /* AdditionalDataTypeExplanations.swift in Sources */, 1402AD16FE4A9A723E7F5827 /* AppCommands.swift in Sources */, diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift index 97a119d..d502814 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift @@ -101,12 +101,15 @@ let softwareValueRules: [ValueRule] = [ .normal( "System Integrity Protection is on, which is the default.", detail: "It stops any software, even with administrator rights, from changing protected parts of macOS.", + why: "Malware that gets administrator rights still can't modify macOS itself or the apps and files it protects.", + action: "Nothing to do.", confidence: .documented ) case false?: .review( "System Integrity Protection is off.", - detail: "It is normally turned off only on purpose, for example for kernel or driver development.", + detail: "Software with administrator rights can change protected parts of macOS on this Mac. It is normally turned off only on purpose, for example for kernel or driver development.", + why: "Without it, malware or a faulty installer that gets administrator rights can modify macOS itself.", action: "If you didn't turn it off deliberately, start up in macOS Recovery and run csrutil enable in Terminal.", confidence: .documented ) diff --git a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift index bf15ce2..ab60622 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift @@ -30,7 +30,7 @@ enum ValueStatus: String, CaseIterable, Identifiable, Sendable { /// Where a value explanation comes from, so heuristics are never presented as facts. enum ValueConfidence: Sendable, Equatable { - /// Apple documentation describes this value. + /// Apple documentation, or Apple's own System Information strings, describe this value. case documented /// Standard macOS behavior, or a widely used convention the explanation names. case observed @@ -40,8 +40,8 @@ enum ValueConfidence: Sendable, Equatable { var title: String { switch self { case .documented: "Documented by Apple" - case .observed: "Standard behavior" - case .likely: "Likely" + case .observed: "Standard macOS behavior" + case .likely: "Inferred by the app" } } @@ -54,47 +54,96 @@ enum ValueConfidence: Sendable, Equatable { } } +/// What one reported value means. The parts map to the expanded row: the summary is +/// the one-line answer under the value, then what it means on this Mac, why it +/// matters, and what to check. struct ValueExplanation: Sendable, Equatable { let summary: String + /// What this specific value means on this Mac. let detail: String? + /// Why this value matters to the person using the Mac. + let significance: String? let status: ValueStatus let confidence: ValueConfidence? + /// What to check or do next. let suggestedAction: String? + init( + summary: String, + detail: String?, + significance: String? = nil, + status: ValueStatus, + confidence: ValueConfidence?, + suggestedAction: String? + ) { + self.summary = summary + self.detail = detail + self.significance = significance + self.status = status + self.confidence = confidence + self.suggestedAction = suggestedAction + } + static func normal( _ summary: String, detail: String? = nil, + why: String? = nil, action: String? = nil, confidence: ValueConfidence = .observed ) -> ValueExplanation { - ValueExplanation(summary: summary, detail: detail, status: .normal, confidence: confidence, suggestedAction: action) + ValueExplanation( + summary: summary, + detail: detail, + significance: why, + status: .normal, + confidence: confidence, + suggestedAction: action + ) } static func info( _ summary: String, detail: String? = nil, + why: String? = nil, action: String? = nil, confidence: ValueConfidence = .observed ) -> ValueExplanation { - ValueExplanation(summary: summary, detail: detail, status: .informational, confidence: confidence, suggestedAction: action) + ValueExplanation( + summary: summary, + detail: detail, + significance: why, + status: .informational, + confidence: confidence, + suggestedAction: action + ) } static func review( _ summary: String, detail: String? = nil, + why: String? = nil, action: String? = nil, confidence: ValueConfidence = .observed ) -> ValueExplanation { - ValueExplanation(summary: summary, detail: detail, status: .worthReviewing, confidence: confidence, suggestedAction: action) + ValueExplanation( + summary: summary, + detail: detail, + significance: why, + status: .worthReviewing, + confidence: confidence, + suggestedAction: action + ) } + /// The honest answer for a value the app doesn't recognize. It never guesses. static func unexplained(_ reportedValue: String) -> ValueExplanation { ValueExplanation( - summary: "The app doesn't have a specific explanation for the value “\(reportedValue)” yet.", - detail: "The field explanation below still applies. The value is shown exactly as macOS reported it.", + summary: "This value isn't explained yet: the app doesn't recognize “\(reportedValue)”.", + detail: "It's shown exactly as macOS reported it. The app doesn't guess what an unrecognized value means, so it can't say whether this result is normal.", + significance: nil, status: .unknown, confidence: nil, - suggestedAction: nil + suggestedAction: "Open About this field below for what the field records in general." ) } } diff --git a/SystemProfilerExplorer/Core/Export/FindingMarkdown.swift b/SystemProfilerExplorer/Core/Export/FindingMarkdown.swift index 363e48a..9214012 100644 --- a/SystemProfilerExplorer/Core/Export/FindingMarkdown.swift +++ b/SystemProfilerExplorer/Core/Export/FindingMarkdown.swift @@ -13,8 +13,20 @@ func findingMarkdown( let likely: String = if case .likely = valueExplanation.confidence { "Likely: " } else { "" } lines.append("- \(valueExplanation.status.title): \(likely)\(valueExplanation.summary)") + if let detail = valueExplanation.detail { + lines.append("- What this result means: \(detail)") + } + + if let significance = valueExplanation.significance { + lines.append("- Why it matters: \(significance)") + } + if let action = valueExplanation.suggestedAction { - lines.append("- What you can do: \(action)") + lines.append("- What to check: \(action)") + } + + if let confidence = valueExplanation.confidence { + lines.append("- Explanation source: \(confidence.title)") } } diff --git a/SystemProfilerExplorer/Core/Presentation/ValuePanel.swift b/SystemProfilerExplorer/Core/Presentation/ValuePanel.swift new file mode 100644 index 0000000..ca0ea19 --- /dev/null +++ b/SystemProfilerExplorer/Core/Presentation/ValuePanel.swift @@ -0,0 +1,77 @@ +import Foundation + +/// One section of the panel shown when a row is expanded. +struct ValuePanelSection: Sendable, Equatable, Identifiable { + enum Kind: String, Sendable { + case meaning + case significance + case reasons + case action + } + + let kind: Kind + let title: String + let symbolName: String + let lines: [String] + + var id: Kind { kind } +} + +/// The expanded panel leads with the value: what this result means, why it matters, +/// the clues behind an inference, then what to check. Text about the field in general +/// never appears here; it lives in the collapsed About this field area. +func valuePanelSections(for explanation: ValueExplanation) -> [ValuePanelSection] { + var sections: [ValuePanelSection] = [] + + if let detail = explanation.detail { + sections.append(ValuePanelSection( + kind: .meaning, + title: "What this result means", + symbolName: "text.magnifyingglass", + lines: [detail] + )) + } + + if let significance = explanation.significance { + sections.append(ValuePanelSection( + kind: .significance, + title: "Why it matters", + symbolName: "scope", + lines: [significance] + )) + } + + let reasons: [String] = explanation.confidence?.reasons ?? [] + + if !reasons.isEmpty { + sections.append(ValuePanelSection( + kind: .reasons, + title: "Why the app thinks so", + symbolName: "list.bullet", + lines: reasons + )) + } + + if let action = explanation.suggestedAction { + sections.append(ValuePanelSection( + kind: .action, + title: "What to check", + symbolName: "checklist", + lines: [action] + )) + } + + return sections +} + +/// The small line under the value panel that names where the explanation comes from. +func valueSourceLine(for explanation: ValueExplanation) -> String? { + explanation.confidence.map { "Source: \($0.title)" } +} + +/// Field-level text appears once per row, in a small collapsed area. It opens by itself +/// only when the value has no explanation of its own (names, numbers, identifiers), +/// because then it is the only explanation there is. +func aboutThisFieldStartsExpanded(valueExplanation: ValueExplanation?) -> Bool { + valueExplanation == nil +} diff --git a/SystemProfilerExplorer/Features/Scan/ProfileReportView.swift b/SystemProfilerExplorer/Features/Scan/ProfileReportView.swift index 3911a46..9825325 100644 --- a/SystemProfilerExplorer/Features/Scan/ProfileReportView.swift +++ b/SystemProfilerExplorer/Features/Scan/ProfileReportView.swift @@ -1430,26 +1430,21 @@ private struct ScalarProfileRow: View { var body: some View { DisclosureGroup { + // The value comes first; text about the field in general sits in one small + // collapsed area below it, so it isn't repeated at full size on every row. if let valueExplanation { ValueMeaningView(explanation: valueExplanation) } if presentation.isLogContent { DiagnosticLogView(presentation: presentation, openSourceLocation: openThisLocation) - } else if let explanation = presentation.explanation { - FieldExplanationView( - presentation: presentation, - explanation: explanation, - openSourceLocation: openThisLocation - ) } else { - MissingExplanationView( + AboutThisFieldView( presentation: presentation, + startsExpanded: aboutThisFieldStartsExpanded(valueExplanation: valueExplanation), openSourceLocation: openThisLocation ) - } - if !presentation.isLogContent { GlossaryTermsRow(texts: explanationTexts) } } label: { @@ -1492,7 +1487,12 @@ private struct ScalarProfileRow: View { var texts: [String] = [] if let valueExplanation { - texts += [valueExplanation.summary, valueExplanation.detail, valueExplanation.suggestedAction].compactMap { $0 } + texts += [ + valueExplanation.summary, + valueExplanation.detail, + valueExplanation.significance, + valueExplanation.suggestedAction + ].compactMap { $0 } texts += valueExplanation.confidence?.reasons ?? [] } @@ -1505,15 +1505,9 @@ private struct ScalarProfileRow: View { private var scalarHeader: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(presentation.title) - .foregroundStyle(.secondary) - - if detailMode == .developer { - ExplanationCoverageBadge(coverage: explanationCoverage(for: presentation)) - } - } - .frame(maxWidth: 280, alignment: .leading) + Text(presentation.title) + .foregroundStyle(.secondary) + .frame(maxWidth: 280, alignment: .leading) Spacer(minLength: 12) @@ -1558,201 +1552,6 @@ private struct ScalarDeveloperDetails: View { } } -private struct ExplanationCoverageBadge: View { - let coverage: ExplanationCoverage - - var body: some View { - Label { - Text(coverage.title) - .foregroundStyle(.secondary) - } icon: { - Image(systemName: coverage.symbolName) - .foregroundStyle(coverageColor) - } - .font(.caption2.weight(.medium)) - .lineLimit(1) - } - - private var coverageColor: Color { - switch coverage { - case .curatedField: .blue - case .generalDataTypeContext: .blue - case .unrecognizedField: .secondary - } - } -} - -private struct FieldExplanationView: View { - let presentation: FieldPresentation - let explanation: FieldExplanation - let openSourceLocation: (String) -> Void - - @Environment(\.explanationDetailMode) private var detailMode - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Text("About this field") - .font(.caption.weight(.semibold)) - .textCase(.uppercase) - .foregroundStyle(.secondary) - - if detailMode == .developer { - ExplanationCoverageDetail(coverage: explanationCoverage(for: presentation)) - } - - ExplanationSection( - title: "What it means", - symbolName: "text.book.closed", - text: explanation.meaning - ) - - // Beginners get the meaning up front; the careful detail is one click away. - if detailMode == .developer { - detailSections - FieldSourceDetails(presentation: presentation, openSourceLocation: openSourceLocation) - } else { - DisclosureGroup("More about this field") { - detailSections - .padding(.top, 8) - } - .font(.callout) - .accessibilityIdentifier("more-about-field") - } - } - .padding(14) - .background(Color.accentColor.opacity(0.055), in: RoundedRectangle(cornerRadius: 11)) - .padding(.top, 8) - } -} - -extension FieldExplanationView { - @ViewBuilder - fileprivate var detailSections: some View { - VStack(alignment: .leading, spacing: 14) { - ExplanationSection( - title: "Why it matters", - symbolName: "scope", - text: explanation.significance - ) - ExplanationSection( - title: "Interpret carefully", - symbolName: "exclamationmark.bubble", - text: explanation.interpretation - ) - - if let privacy = explanation.privacy { - ExplanationSection( - title: "Privacy", - symbolName: "eye.slash", - text: privacy - ) - } - } - } -} - -private struct FieldSourceDetails: View { - let presentation: FieldPresentation - let openSourceLocation: (String) -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - Divider() - - VStack(alignment: .leading, spacing: 5) { - LabeledContent("Source field", value: presentation.sourcePath) - - if presentation.displayedValue != presentation.rawValue { - LabeledContent("Raw value", value: presentation.rawValue) - } - } - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - - Button { - openSourceLocation(presentation.sourcePath) - } label: { - Label("Show Raw Source Location", systemImage: "arrow.turn.down.right") - } - .buttonStyle(.bordered) - .accessibilityIdentifier("open-raw-source-\(presentation.sourcePath)") - } - } -} - -private struct ExplanationCoverageDetail: View { - let coverage: ExplanationCoverage - - var body: some View { - HStack(spacing: 7) { - Image(systemName: coverage.symbolName) - VStack(alignment: .leading, spacing: 1) { - Text(coverage.title) - .font(.caption.weight(.semibold)) - Text(coverage.detail) - .font(.caption2) - } - } - .foregroundStyle(.secondary) - .padding(9) - .background(.quaternary.opacity(0.55), in: RoundedRectangle(cornerRadius: 8)) - } -} - -private struct ExplanationSection: View { - let title: String - let symbolName: String - let text: String - - var body: some View { - VStack(alignment: .leading, spacing: 5) { - Label(title, systemImage: symbolName) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Color.accentColor) - Text(text) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - .textSelection(.enabled) - } - } -} - -private struct MissingExplanationView: View { - let presentation: FieldPresentation - let openSourceLocation: (String) -> Void - - @Environment(\.explanationDetailMode) private var detailMode - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Label("Unrecognized field", systemImage: "questionmark.circle") - .font(.subheadline.weight(.semibold)) - Text("The value is preserved exactly as system_profiler reported it. The app does not infer a meaning for an unrecognized field.") - .font(.callout) - .foregroundStyle(.secondary) - - if detailMode == .developer { - LabeledContent("Source field", value: presentation.sourcePath) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - - Button { - openSourceLocation(presentation.sourcePath) - } label: { - Label("Show Raw Source Location", systemImage: "arrow.turn.down.right") - } - .buttonStyle(.bordered) - } - } - .padding(14) - .background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 11)) - .padding(.top, 8) - } -} - private struct ScanProvenanceView: View { let report: SystemProfilerReport diff --git a/SystemProfilerExplorer/Features/Scan/ValueExplanationViews.swift b/SystemProfilerExplorer/Features/Scan/ValueExplanationViews.swift index 7f7a025..3a7d65e 100644 --- a/SystemProfilerExplorer/Features/Scan/ValueExplanationViews.swift +++ b/SystemProfilerExplorer/Features/Scan/ValueExplanationViews.swift @@ -60,86 +60,63 @@ struct ValueSummaryLine: View { } } -/// The expanded "What this value means on your Mac" panel. The row already shows the -/// one-line summary, so the panel adds only the detail, reasons, and next step. +/// The first thing in an expanded row: what this value means, why it matters, and what +/// to check. The row already shows the one-line summary, so the panel adds the rest. struct ValueMeaningView: View { let explanation: ValueExplanation - @Environment(\.explanationDetailMode) private var detailMode - var body: some View { - if hasContent { - panel - } - } - - private var reasons: [String] { - explanation.confidence?.reasons ?? [] - } - - private var hasContent: Bool { - explanation.detail != nil - || !reasons.isEmpty - || explanation.suggestedAction != nil - || (detailMode == .developer && explanation.confidence != nil) - } - - private var panel: some View { - VStack(alignment: .leading, spacing: 12) { - if let detail = explanation.detail { - ValueMeaningSection(title: "What this value means on your Mac", symbolName: "text.magnifyingglass") { - Text(detail) - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - } - } + let sections: [ValuePanelSection] = valuePanelSections(for: explanation) - if !reasons.isEmpty { - ValueMeaningSection(title: "Why the app thinks so", symbolName: "list.bullet") { - VStack(alignment: .leading, spacing: 4) { - ForEach(reasons, id: \.self) { reason in - Label(reason, systemImage: "circle.fill") - .labelStyle(ReasonLabelStyle()) - } - } + if !sections.isEmpty { + VStack(alignment: .leading, spacing: 12) { + ForEach(sections) { section in + ValueMeaningSection(section: section) } - } - if let action = explanation.suggestedAction { - ValueMeaningSection(title: "What you can do", symbolName: "hand.point.right") { - Text(action) - .font(.callout) - .fixedSize(horizontal: false, vertical: true) + if let source = valueSourceLine(for: explanation) { + Text(source) + .font(.caption) + .foregroundStyle(.secondary) + .accessibilityIdentifier("value-source") } } - - if detailMode == .developer, let confidence = explanation.confidence { - Text("Explanation source: \(confidence.title)") - .font(.caption) - .foregroundStyle(.secondary) - } + .textSelection(.enabled) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(explanation.status.tint.opacity(0.07), in: RoundedRectangle(cornerRadius: 11)) + .padding(.top, 8) + .accessibilityIdentifier("value-meaning") } - .textSelection(.enabled) - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background(explanation.status.tint.opacity(0.07), in: RoundedRectangle(cornerRadius: 11)) - .padding(.top, 8) - .accessibilityIdentifier("value-meaning") } } -private struct ValueMeaningSection: View { - let title: String - let symbolName: String - @ViewBuilder let content: () -> Content +private struct ValueMeaningSection: View { + let section: ValuePanelSection var body: some View { VStack(alignment: .leading, spacing: 5) { - Label(title, systemImage: symbolName) + Label(section.title, systemImage: section.symbolName) .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.accentColor) - content() + + if section.kind == .reasons { + VStack(alignment: .leading, spacing: 4) { + ForEach(section.lines, id: \.self) { reason in + Label(reason, systemImage: "circle.fill") + .labelStyle(ReasonLabelStyle()) + } + } + } else { + ForEach(section.lines, id: \.self) { line in + Text(line) + .font(.callout) + .fixedSize(horizontal: false, vertical: true) + } + } } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("value-section-\(section.kind.rawValue)") } } @@ -158,6 +135,126 @@ private struct ReasonLabelStyle: LabelStyle { } } +/// Text about the field in general, shown once per row in a small collapsed area so it +/// never crowds out what the value means. It holds the explanation coverage note too. +struct AboutThisFieldView: View { + let presentation: FieldPresentation + let openSourceLocation: (String) -> Void + + @Environment(\.explanationDetailMode) private var detailMode + @State private var isExpanded: Bool + + init(presentation: FieldPresentation, startsExpanded: Bool, openSourceLocation: @escaping (String) -> Void) { + self.presentation = presentation + self.openSourceLocation = openSourceLocation + _isExpanded = State(initialValue: startsExpanded) + } + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 10) { + if let explanation = presentation.explanation { + FieldNote(title: "What the field records", text: explanation.meaning) + FieldNote(title: "Why the field matters", text: explanation.significance) + FieldNote(title: "Interpret carefully", text: explanation.interpretation) + + if let privacy = explanation.privacy { + FieldNote(title: "Privacy", text: privacy) + } + } else { + FieldNote( + title: "Unrecognized field", + text: "The value is preserved exactly as system_profiler reported it. The app does not infer a meaning for an unrecognized field." + ) + } + + CoverageNote(coverage: explanationCoverage(for: presentation)) + + if detailMode == .developer { + FieldSourceDetails(presentation: presentation, openSourceLocation: openSourceLocation) + } + } + .textSelection(.enabled) + .padding(.top, 6) + } label: { + Label("About this field", systemImage: "info.circle") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 9)) + .padding(.top, 8) + .accessibilityIdentifier("about-this-field") + } +} + +private struct FieldNote: View { + let title: String + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.caption.weight(.semibold)) + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } +} + +/// The explanation coverage note, such as "Curated explanation". It describes the app's +/// catalog, not the Mac, so it sits with the field notes rather than on every row. +private struct CoverageNote: View { + let coverage: ExplanationCoverage + + var body: some View { + Label { + Text("\(Text(coverage.title).fontWeight(.semibold)). \(coverage.detail)") + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: coverage.symbolName) + } + .font(.caption2) + .foregroundStyle(.secondary) + .accessibilityIdentifier("explanation-coverage") + } +} + +private struct FieldSourceDetails: View { + let presentation: FieldPresentation + let openSourceLocation: (String) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Divider() + + VStack(alignment: .leading, spacing: 4) { + LabeledContent("Source field", value: presentation.sourcePath) + + if presentation.displayedValue != presentation.rawValue { + LabeledContent("Raw value", value: presentation.rawValue) + } + } + .font(.caption) + .foregroundStyle(.secondary) + + Button { + openSourceLocation(presentation.sourcePath) + } label: { + Label("Show Raw Source Location", systemImage: "arrow.turn.down.right") + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("open-raw-source-\(presentation.sourcePath)") + } + } +} + /// Plain-language sentences at the top of a report, built from collected values, followed /// by the values worth a look so the answer to "is this Mac OK?" is on the first screen. struct AtAGlanceCard: View { diff --git a/SystemProfilerExplorerTests/ValueExplanationTests.swift b/SystemProfilerExplorerTests/ValueExplanationTests.swift index 03a4a3f..ad94c2a 100644 --- a/SystemProfilerExplorerTests/ValueExplanationTests.swift +++ b/SystemProfilerExplorerTests/ValueExplanationTests.swift @@ -350,7 +350,10 @@ struct FindingMarkdownTests { #expect(lines.first?.hasPrefix("**") == true) #expect(markdown.contains("- Worth a look: System Integrity Protection is off.")) - #expect(markdown.contains("- What you can do: ")) + #expect(markdown.contains("- What this result means: ")) + #expect(markdown.contains("- Why it matters: ")) + #expect(markdown.contains("- What to check: ")) + #expect(markdown.contains("- Explanation source: Documented by Apple")) #expect(markdown.contains("- Source: `SPSoftwareDataType.system_integrity`")) #expect(markdown.contains("raw value `integrity_disabled`")) } diff --git a/SystemProfilerExplorerTests/ValuePanelTests.swift b/SystemProfilerExplorerTests/ValuePanelTests.swift new file mode 100644 index 0000000..e365b01 --- /dev/null +++ b/SystemProfilerExplorerTests/ValuePanelTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing +@testable import SystemProfilerExplorer + +/// The expanded row leads with the value; text about the field stays in one small, +/// collapsed About this field area. +struct ValuePanelTests { + @Test + func panelLeadsWithTheValueInReadingOrder() throws { + let explanation = try #require(valueExplanation( + dataType: .software, + path: ["system_integrity"], + scalar: .string("integrity_disabled") + )) + let sections: [ValuePanelSection] = valuePanelSections(for: explanation) + + #expect(sections.map(\.kind) == [.meaning, .significance, .action]) + #expect(sections.map(\.title) == ["What this result means", "Why it matters", "What to check"]) + #expect(valueSourceLine(for: explanation) == "Source: Documented by Apple") + } + + @Test + func inferredValuesListTheirCluesBeforeWhatToCheck() { + let explanation: ValueExplanation = .info( + "A summary.", + detail: "What it means.", + why: "Why it matters.", + action: "What to check.", + confidence: .likely(reasons: ["First clue.", "Second clue."]) + ) + let sections: [ValuePanelSection] = valuePanelSections(for: explanation) + + #expect(sections.map(\.kind) == [.meaning, .significance, .reasons, .action]) + #expect(sections.first { $0.kind == .reasons }?.lines == ["First clue.", "Second clue."]) + #expect(valueSourceLine(for: explanation) == "Source: Inferred by the app") + } + + @Test + func fieldTextNeverAppearsInTheValuePanel() throws { + let path: [String] = ["system_integrity"] + let scalar: ProfileScalar = .string("integrity_enabled") + let field = try #require(fieldPresentation(dataType: .software, path: path, scalar: scalar).explanation) + let value = try #require(valueExplanation(dataType: .software, path: path, scalar: scalar)) + let panelText: [String] = valuePanelSections(for: value).flatMap(\.lines) + + #expect(!panelText.isEmpty) + #expect(!panelText.contains(field.meaning)) + #expect(!panelText.contains(field.significance)) + #expect(!panelText.contains(field.interpretation)) + } + + @Test + func aboutThisFieldOpensOnlyWhenTheValueHasNoExplanation() throws { + let explained = try #require(valueExplanation( + dataType: .software, + path: ["system_integrity"], + scalar: .string("integrity_enabled") + )) + + #expect(aboutThisFieldStartsExpanded(valueExplanation: explained) == false) + #expect(aboutThisFieldStartsExpanded(valueExplanation: .unexplained("future_value")) == false) + #expect(aboutThisFieldStartsExpanded(valueExplanation: nil) == true) + } + + @Test + func unrecognizedValuesSayTheyArentExplainedAndNeverGuess() throws { + let explanation = try #require(valueExplanation( + dataType: .software, + path: ["system_integrity"], + scalar: .string("integrity_future_state") + )) + + #expect(explanation.status == .unknown) + #expect(explanation.summary.contains("isn't explained yet")) + #expect(explanation.summary.contains("integrity_future_state")) + #expect(explanation.confidence == nil) + #expect(explanation.significance == nil) + #expect(valueSourceLine(for: explanation) == nil) + #expect(explanation.suggestedAction?.contains("About this field") == true) + } + + @Test + func confidenceTitlesNameTheSource() { + #expect(ValueConfidence.documented.title == "Documented by Apple") + #expect(ValueConfidence.observed.title == "Standard macOS behavior") + #expect(ValueConfidence.likely(reasons: []).title == "Inferred by the app") + } +} From 2ca13e41e1700b6056c3d95074f55ddc52d95c9c Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:44:06 +0000 Subject: [PATCH 03/15] Explain every architecture and origin value for apps and frameworks Each arch_kind value now says what it means on this Mac, why it matters, and what to check. The explanation uses the Hardware section, when it was scanned, to tell whether the Mac has Apple silicon or an Intel processor: an Intel-only app runs through Rosetta 2 on Apple silicon but natively on an Intel Mac, and an Apple silicon app can't open on an Intel Mac. arch_i32_i64 and arch_i32 come from Apple's System Information strings; arch_ppc is kept but marked unconfirmed. Where an app, framework, or extension came from, and whether a framework is private, get the same full explanations. A new catalog test checks that every known value has every part, and docs/value-explanations.md lists each value with its source and whether its spelling is confirmed. --- PLAN.md | 2 +- .../project.pbxproj | 4 + .../Values/SoftwareArtifactValueRules.swift | 254 +++++++++++++++--- .../Values/ValueExplanation.swift | 33 ++- .../ValueCatalogTests.swift | 146 ++++++++++ docs/value-explanations.md | 72 +++++ 6 files changed, 465 insertions(+), 46 deletions(-) create mode 100644 SystemProfilerExplorerTests/ValueCatalogTests.swift create mode 100644 docs/value-explanations.md diff --git a/PLAN.md b/PLAN.md index ecb5dea..9b259a1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -72,7 +72,7 @@ test run. 2. [x] Layout: add "why it matters" to value explanations, build the value-first panel and the collapsed About this field area, move the coverage note into it, and include the new parts in Copy as Markdown. -3. [ ] Applications and frameworks (most rows in a scan): architecture +3. [x] Applications and frameworks (most rows in a scan): architecture (`arch_kind`), where it came from (`obtained_from`), and private frameworks. 4. [ ] Fonts (the most values in a scan): font kind, enabled, valid, diff --git a/SystemProfilerExplorer.xcodeproj/project.pbxproj b/SystemProfilerExplorer.xcodeproj/project.pbxproj index 4dd4ae5..c0b96b9 100644 --- a/SystemProfilerExplorer.xcodeproj/project.pbxproj +++ b/SystemProfilerExplorer.xcodeproj/project.pbxproj @@ -104,6 +104,7 @@ FE864288671D00433353FBB2 /* CollectionCoverageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 949093927A321A77B7F5D829 /* CollectionCoverageTests.swift */; }; D0EB1B42212523FE20AEC875 /* ValuePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 782B45221FBF672C566ABAC4 /* ValuePanel.swift */; }; B2C53C0C964281379BF8AEB3 /* ValuePanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */; }; + B89D0EC021ABEEDFA1DE36B0 /* ValueCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A841B78A28F111C12390310A /* ValueCatalogTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -216,6 +217,7 @@ FCB5B24DFE76BB7DE34B6878 /* DiagnosticLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLogView.swift; sourceTree = ""; }; 782B45221FBF672C566ABAC4 /* ValuePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePanel.swift; sourceTree = ""; }; C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePanelTests.swift; sourceTree = ""; }; + A841B78A28F111C12390310A /* ValueCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValueCatalogTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -285,6 +287,7 @@ 387CBF80EA9183D8E5CB8111 /* SystemProfilerExplorerTests */ = { isa = PBXGroup; children = ( + A841B78A28F111C12390310A /* ValueCatalogTests.swift */, C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */, 5E4302147D18912C4D6C9E8F /* AdditionalExplanationCoverageTests.swift */, D71F24CE50090E539F32B351 /* ChangeInsightTests.swift */, @@ -602,6 +605,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B89D0EC021ABEEDFA1DE36B0 /* ValueCatalogTests.swift in Sources */, B2C53C0C964281379BF8AEB3 /* ValuePanelTests.swift in Sources */, 41A352BEAE8240924801481C /* AdditionalExplanationCoverageTests.swift in Sources */, 8D9B09B8B72B1F0B2F5873B1 /* ChangeInsightTests.swift in Sources */, diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift index b641e08..e2b6e06 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift @@ -4,51 +4,15 @@ import Foundation let softwareArtifactValueRules: [ValueRule] = [ ValueRule(.applications, .frameworks, field: "arch_kind") { context in - switch context.reportedValue { - case "arch_arm": - .normal("Runs natively on Apple silicon.", confidence: .documented) - case "arch_arm_i64": - .normal("Universal: runs natively on both Apple silicon and Intel Macs.", confidence: .documented) - case "arch_i64": - .info( - "Built for Intel Macs only.", - detail: "On a Mac with Apple silicon it runs through Rosetta 2 translation, which works well but uses more power.", - confidence: .documented - ) - case "arch_i32", "arch_ppc": - .info("Built for an old processor type that current macOS can't run.", confidence: .documented) - case "arch_ios": - .info("An iPhone or iPad app running on this Mac.", confidence: .documented) - case "arch_other": - .info("system_profiler didn't classify this code as Apple silicon, Intel, or iPhone and iPad code.") - default: - nil - } + architectureExplanation( + context.reportedValue, + item: context.dataType == .frameworks ? "framework" : "app", + processor: context.report.processor + ) }, ValueRule(.applications, .frameworks, .extensions, field: "obtained_from") { context in - switch context.reportedValue { - case "apple": - .normal("Made by Apple and included with macOS or an Apple update.", confidence: .documented) - case "mac_app_store": - .normal("Installed from the Mac App Store.", confidence: .documented) - case "ios_app_store": - .normal("Installed from the App Store as an iPhone or iPad app.", confidence: .documented) - case "identified_developer": - .normal( - "From an identified developer: signed with an Apple Developer ID.", - detail: "Gatekeeper checks this signature, and usually Apple's notarization, before the app first opens.", - confidence: .documented - ) - case "unknown": - .info( - "macOS couldn't determine where this came from. It usually isn't signed with a Developer ID.", - detail: "Unsigned code isn't necessarily harmful; developer tools and scripts are often unsigned. Keep only software you recognize.", - confidence: .documented - ) - default: - nil - } + originExplanation(context.reportedValue) }, ValueRule(.installHistory, field: "package_source") { context in @@ -87,6 +51,196 @@ let softwareArtifactValueRules: [ValueRule] = [ } ] +// MARK: - Architecture and origin + +private let rosettaFuture: String = + "Apple has said Rosetta 2 stays fully available through macOS 27; after that it will only be kept for some older games, so Intel-only software may stop opening in a later macOS." + +/// Explains `arch_kind`, the kind of code an app or framework contains. +/// +/// Sources: `arch_arm`, `arch_arm_i64`, `arch_i64`, `arch_ios`, and `arch_other` appear in +/// docs/value-inventory.md. `arch_i32` ("32-bit (Unsupported)") and `arch_i32_i64` +/// ("32/64-bit") are keys in Apple's System Information localization strings. +/// `arch_ppc` is unconfirmed: no source shows this spelling. +func architectureExplanation(_ value: String, item: String, processor: MacProcessorFamily?) -> ValueExplanation? { + switch value { + case "arch_arm": + return nativeAppleSiliconExplanation(item: item, processor: processor) + case "arch_arm_i64": + return .normal( + "Universal: runs natively on both Apple silicon and Intel Macs.", + detail: "The \(item) contains code for both kinds of processor, and macOS picks \(processorPhrase(processor, appleSilicon: "the Apple silicon code on this Mac", intel: "the Intel code on this Mac", unknown: "the code that matches the Mac it runs on")).", + why: "It runs at full speed without Rosetta 2 translation, and keeps working if you move to another kind of Mac.", + action: "Nothing to do.", + confidence: .documented + ) + case "arch_i64", "arch_i32_i64": + return intelExplanation(value, item: item, processor: processor) + case "arch_ios": + return .info( + "An iPhone or iPad app running on this Mac.", + detail: "Macs with Apple silicon can run many iPhone and iPad apps from the App Store. It runs natively, but keeps its iPhone or iPad design. system_profiler also reports this for some Mac apps whose developer didn't mark them as Mac apps, so a familiar Mac app can show up here too.", + why: "iPhone and iPad apps can behave differently from Mac apps, for example with touch-style controls, fixed window sizes, or missing menu commands.", + action: "Nothing to do. If the app feels awkward on the Mac, check whether the developer offers a Mac version.", + confidence: .documented + ) + case "arch_other": + return .info( + "Not Apple silicon, Intel, or iPhone code that System Information recognizes.", + detail: "This is common for \(item)s whose main program is a script, such as a shell or Python launcher, which then starts other code.", + why: "The label doesn't say whether the \(item) runs natively or through Rosetta 2. On its own, it isn't a sign of a problem.", + action: "Nothing to do unless the \(item) misbehaves. If it does, check with its developer for a current version.", + confidence: .likely(reasons: [ + "Published system_profiler output shows this value for apps whose main program is a shell script rather than compiled code.", + "The value's name says the code is of another kind." + ]) + ) + case "arch_i32": + return .info( + "32-bit Intel code, which current macOS can't run.", + detail: "macOS Catalina (10.15) and later only run 64-bit code, so this \(item) can't open on this Mac.", + why: "It uses disk space but can't be used here.", + action: "Remove it, or look for a 64-bit version from its developer.", + confidence: .documented + ) + case "arch_ppc": + // Unconfirmed spelling: kept so an old PowerPC app is explained if it appears. + return .info( + "Built for PowerPC processors, which no current Mac can run.", + detail: "PowerPC apps stopped working in Mac OS X Lion (10.7), so this \(item) can't open on this Mac.", + why: "It uses disk space but can't be used here.", + action: "Remove it, or look for a current version from its developer.", + confidence: .documented + ) + default: + return nil + } +} + +private func nativeAppleSiliconExplanation(item: String, processor: MacProcessorFamily?) -> ValueExplanation { + switch processor { + case .intel?: + .info( + "Built only for Macs with Apple silicon.", + detail: "This Mac has an Intel processor, so this \(item) can't run on it.", + why: "Opening it fails with a message that it isn't supported on this Mac.", + action: "Check whether the developer offers an Intel or Universal version, or remove it if you don't need it.", + confidence: .documented + ) + case .appleSilicon?: + .normal( + "Runs natively on Apple silicon.", + detail: "It runs directly on this Mac's Apple silicon chip, with no translation.", + why: "Native code starts faster, runs faster, and uses less power than code translated by Rosetta 2.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + .normal( + "Runs natively on Apple silicon.", + detail: "It runs directly on a Mac with Apple silicon, with no translation, but can't run on an Intel Mac. Scan Hardware to see which processor this Mac has.", + why: "Native code starts faster, runs faster, and uses less power than code translated by Rosetta 2.", + action: "Nothing to do on a Mac with Apple silicon.", + confidence: .documented + ) + } +} + +private func intelExplanation(_ value: String, item: String, processor: MacProcessorFamily?) -> ValueExplanation { + let kind: String = value == "arch_i32_i64" + ? "Built for Intel Macs only, with both 32-bit and 64-bit code." + : "Built for Intel Macs only." + let olderCode: String = value == "arch_i32_i64" ? " Current macOS uses only its 64-bit code." : "" + + switch processor { + case .intel?: + return .normal( + kind, + detail: "It runs natively on this Mac's Intel processor.\(olderCode)", + why: "On a Mac with Apple silicon it would need Rosetta 2 translation. \(rosettaFuture)", + action: "Nothing to do on this Mac. Before moving to a Mac with Apple silicon, check for a Universal version.", + confidence: .documented + ) + case .appleSilicon?: + return .info( + kind, + detail: "This Mac has Apple silicon, so the \(item) runs through Rosetta 2, which translates its Intel code.\(olderCode)", + why: "Translated code uses more power and can be slower. \(rosettaFuture)", + action: "Check for an Apple silicon or Universal version: look for an update, or on the developer's website.", + confidence: .documented + ) + case nil: + return .info( + kind, + detail: "On a Mac with Apple silicon it runs through Rosetta 2, which translates its Intel code. On an Intel Mac it runs natively.\(olderCode)", + why: "Translated code uses more power and can be slower. \(rosettaFuture)", + action: "On a Mac with Apple silicon, check for an Apple silicon or Universal version.", + confidence: .documented + ) + } +} + +private func processorPhrase(_ processor: MacProcessorFamily?, appleSilicon: String, intel: String, unknown: String) -> String { + switch processor { + case .appleSilicon?: appleSilicon + case .intel?: intel + case nil: unknown + } +} + +/// Explains `obtained_from`, where macOS says an app, framework, or extension came from. +/// +/// Sources: `apple`, `mac_app_store`, `identified_developer`, and `unknown` are keys in +/// Apple's System Information localization strings and appear in published output. +/// `app_store` ("App Store") is also an Apple key. `ios_app_store` is unconfirmed. +func originExplanation(_ value: String) -> ValueExplanation? { + switch value { + case "apple": + .normal( + "Made by Apple and included with macOS or an Apple update.", + detail: "It's signed by Apple and came with macOS, an Apple app, or an Apple update.", + why: "Apple software is updated along with macOS or through the App Store.", + action: "Nothing to do.", + confidence: .documented + ) + case "mac_app_store", "app_store": + .normal( + "Installed from the Mac App Store.", + detail: "Apple reviewed it before it was listed, and it's signed by Apple for the App Store.", + why: "App Store apps run in a sandbox that limits what they can reach without your permission, and they update through the App Store.", + action: "Nothing to do. Updates come from the App Store.", + confidence: .documented + ) + case "ios_app_store": + // Unconfirmed spelling. + .normal( + "Installed from the App Store as an iPhone or iPad app.", + detail: "Apple reviewed it before it was listed, and it runs on this Mac as an iPhone or iPad app.", + why: "App Store apps run in a sandbox that limits what they can reach without your permission, and they update through the App Store.", + action: "Nothing to do. Updates come from the App Store.", + confidence: .documented + ) + case "identified_developer": + .normal( + "From an identified developer: signed with an Apple Developer ID.", + detail: "It was downloaded from outside the App Store. Gatekeeper checks this signature, and usually Apple's notarization, before the app first opens.", + why: "The signature shows who made it and that it hasn't changed since it was signed. Apple can revoke a Developer ID that's used for malware.", + action: "Nothing to do if you recognize it. Its updates come from the developer, not the App Store.", + confidence: .documented + ) + case "unknown": + .info( + "macOS couldn't determine where this came from. It usually isn't signed with a Developer ID.", + detail: "It isn't signed by Apple, the App Store, or a Developer ID, or its signature couldn't be checked. Developer tools, scripts, and things you built yourself are often like this.", + why: "Without a trusted signature, macOS can't show who made it or confirm that it hasn't been changed. That alone doesn't mean it's harmful.", + action: "Keep it if you know where it came from. If you don't, look at its location and remove it if you don't need it.", + confidence: .documented + ) + default: + nil + } +} + // MARK: - Fonts, frameworks, extensions, and the Secure Element let softwareFlagValueRules: [ValueRule] = [ @@ -164,9 +318,21 @@ let softwareFlagValueRules: [ValueRule] = [ ValueRule(.frameworks, field: "private_framework") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .info("A private framework: Apple uses it inside macOS and doesn't support other apps using it.", confidence: .documented) + .info( + "A private framework: Apple uses it inside macOS and doesn't support other apps using it.", + detail: "It's shared code that parts of macOS load, not something you open. Apple doesn't publish it for other developers.", + why: "Private frameworks can change or disappear in any macOS update, which is why apps that rely on them sometimes break after updating.", + action: "Nothing to do. It belongs to macOS; don't remove it.", + confidence: .documented + ) case false?: - .info("A public framework that apps are meant to use.", confidence: .documented) + .info( + "A public framework that apps are meant to use.", + detail: "It's shared code that apps can load. Its developer, often Apple, publishes it for other software to build on.", + why: "Many apps may depend on it, so removing or replacing it can stop them from opening.", + action: "Nothing to do. If it isn't Apple's, it usually came with an app or a driver you installed.", + confidence: .documented + ) case nil: nil } diff --git a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift index ab60622..acee9fd 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift @@ -154,10 +154,37 @@ struct ValueReportContext: Sendable, Equatable { let usbDeviceNames: [String]? /// Mount points of the storage volumes in the report. var storageMountPoints: [String] = [] + /// The kind of processor this Mac has, or nil when the report has no Hardware section. + var processor: MacProcessorFamily? static let empty: ValueReportContext = ValueReportContext(usbDeviceNames: nil) } +enum MacProcessorFamily: Sendable, Equatable { + case appleSilicon + case intel +} + +/// Reads the processor family from the Hardware overview: `chip_type` names an Apple +/// chip on Apple silicon, and `cpu_type` names an Intel processor on Intel Macs. +func processorFamily(inHardwareItems items: [ProfileValue]) -> MacProcessorFamily? { + for item in items { + guard case let .object(overview) = item else { + continue + } + + if case let .string(chip)? = overview["chip_type"], chip.hasPrefix("Apple") { + return .appleSilicon + } + + if case let .string(cpu)? = overview["cpu_type"], cpu.localizedCaseInsensitiveContains("Intel") { + return .intel + } + } + + return nil +} + func valueReportContext(for report: SystemProfilerReport) -> ValueReportContext { var usbDeviceNames: [String]? @@ -178,7 +205,11 @@ func valueReportContext(for report: SystemProfilerReport) -> ValueReportContext return mountPoint } - return ValueReportContext(usbDeviceNames: usbDeviceNames, storageMountPoints: mountPoints) + let processor: MacProcessorFamily? = report.sections + .first(where: { $0.dataType == .hardware }) + .flatMap { processorFamily(inHardwareItems: $0.items) } + + return ValueReportContext(usbDeviceNames: usbDeviceNames, storageMountPoints: mountPoints, processor: processor) } private func collectDeviceNames(_ value: ProfileValue, into names: inout [String]) { diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift new file mode 100644 index 0000000..7609af1 --- /dev/null +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -0,0 +1,146 @@ +import Foundation +import Testing +@testable import SystemProfilerExplorer + +/// One known value of a limited-set field, as system_profiler reports it. +/// docs/value-explanations.md lists the same values with their sources. +struct ValueSample: Sendable, CustomTestStringConvertible { + let dataType: SystemProfilerDataType + let path: [String] + let scalar: ProfileScalar + let siblings: [String: ProfileValue] + let report: ValueReportContext + + init( + _ dataType: SystemProfilerDataType, + _ path: [String], + _ value: String, + siblings: [String: ProfileValue] = [:], + report: ValueReportContext = .empty + ) { + self.init(dataType, path, scalar: .string(value), siblings: siblings, report: report) + } + + init( + _ dataType: SystemProfilerDataType, + _ path: [String], + scalar: ProfileScalar, + siblings: [String: ProfileValue] = [:], + report: ValueReportContext = .empty + ) { + self.dataType = dataType + self.path = path + self.scalar = scalar + self.siblings = siblings + self.report = report + } + + var testDescription: String { + "\(dataType.rawValue).\(path.joined(separator: ".")) = \(scalar.rawDescription)" + } + + func explain() -> ValueExplanation? { + valueExplanation(dataType: dataType, path: path, scalar: scalar, siblings: siblings, report: report) + } +} + +let appleSiliconReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, processor: .appleSilicon) +let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, processor: .intel) + +/// Every value the app explains for fields with a limited set of values. +let explainedValueSamples: [ValueSample] = applicationValueSamples + +/// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, +/// because what an architecture means depends on the Mac. +private let applicationValueSamples: [ValueSample] = { + let architectures: [String] = [ + "arch_arm", "arch_arm_i64", "arch_i64", "arch_i32_i64", "arch_ios", "arch_other", "arch_i32", "arch_ppc" + ] + let origins: [String] = ["apple", "mac_app_store", "app_store", "ios_app_store", "identified_developer", "unknown"] + var samples: [ValueSample] = [] + + for dataType in [SystemProfilerDataType.applications, .frameworks] { + for architecture in architectures { + for report in [ValueReportContext.empty, appleSiliconReport, intelReport] { + samples.append(ValueSample(dataType, ["arch_kind"], architecture, report: report)) + } + } + + for origin in origins { + samples.append(ValueSample(dataType, ["obtained_from"], origin)) + } + } + + samples += origins.map { ValueSample(.extensions, ["obtained_from"], $0) } + samples += ["yes", "no"].map { ValueSample(.frameworks, ["private_framework"], $0) } + return samples +}() + +struct ValueCatalogTests { + @Test(arguments: explainedValueSamples) + func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { + let explanation = try #require(sample.explain(), "\(sample.testDescription) has no explanation") + + #expect(explanation.status != .unknown) + #expect(!explanation.summary.isEmpty) + #expect(explanation.detail?.isEmpty == false, "missing what this result means") + #expect(explanation.significance?.isEmpty == false, "missing why it matters") + #expect(explanation.suggestedAction?.isEmpty == false, "missing what to check") + #expect(explanation.confidence != nil, "missing a source") + } + + // MARK: - Applications and frameworks + + @Test + func architectureDependsOnThisMacsProcessor() throws { + func explain(_ value: String, _ report: ValueReportContext) -> ValueExplanation? { + valueExplanation(dataType: .applications, path: ["arch_kind"], scalar: .string(value), report: report) + } + + let intelOnAppleSilicon = try #require(explain("arch_i64", appleSiliconReport)) + let intelOnIntel = try #require(explain("arch_i64", intelReport)) + let armOnIntel = try #require(explain("arch_arm", intelReport)) + + #expect(intelOnAppleSilicon.status == .informational) + #expect(intelOnAppleSilicon.detail?.contains("Rosetta 2") == true) + #expect(intelOnAppleSilicon.significance?.contains("macOS 27") == true) + #expect(intelOnIntel.status == .normal) + #expect(intelOnIntel.detail?.contains("natively") == true) + #expect(armOnIntel.status == .informational) + #expect(armOnIntel.detail?.contains("can't run") == true) + #expect(explain("arch_arm", appleSiliconReport)?.status == .normal) + #expect(explain("arch_arm_i64", intelReport)?.status == .normal) + #expect(explain("arch_future", appleSiliconReport)?.status == .unknown) + } + + @Test + func frameworksAreNotCalledApps() throws { + let framework = try #require(valueExplanation( + dataType: .frameworks, + path: ["arch_kind"], + scalar: .string("arch_other") + )) + + #expect(framework.detail?.contains("frameworks whose main program") == true) + #expect(framework.confidence?.reasons.isEmpty == false) + } + + @Test + func unsignedOriginIsInformationNotAWarning() throws { + let unknown = try #require(valueExplanation(dataType: .applications, path: ["obtained_from"], scalar: .string("unknown"))) + + #expect(unknown.status == .informational) + #expect(unknown.significance?.contains("doesn't mean it's harmful") == true) + #expect(valueExplanation(dataType: .applications, path: ["obtained_from"], scalar: .string("somewhere_else"))?.status == .unknown) + } + + @Test + func processorFamilyComesFromTheHardwareOverview() { + let appleSilicon: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "chip_type": .string("Apple M1")])] + let intel: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "cpu_type": .string("Quad-Core Intel Core i5")])] + + #expect(processorFamily(inHardwareItems: appleSilicon) == .appleSilicon) + #expect(processorFamily(inHardwareItems: intel) == .intel) + #expect(processorFamily(inHardwareItems: [.object(["_name": .string("hardware_overview")])]) == nil) + } +} diff --git a/docs/value-explanations.md b/docs/value-explanations.md new file mode 100644 index 0000000..958804d --- /dev/null +++ b/docs/value-explanations.md @@ -0,0 +1,72 @@ +# Value explanations + +Every value the app explains for fields with a limited set of values, with the +source of the explanation and whether the exact spelling is confirmed. The +rules live in `SystemProfilerExplorer/Core/Explanations/Values`, and +`SystemProfilerExplorerTests/ValueCatalogTests.swift` checks that each value +below has every part of an explanation. + +Each explained value has a one-line summary, what it means on this Mac, why it +matters, what to check, a status, and a source. A value that isn't listed for a +field that has a rule is shown as **Not yet explained**; the app never guesses. + +## Key + +**Status:** Normal, Info, or Worth a look. Some values change status with the +rest of the report (for example the processor, or whether a network is the +current one); the table says so. + +**Source:** + +- **Apple**: Apple documentation, or Apple's own System Information strings. +- **Standard**: standard macOS behavior or a widely used convention. +- **Inferred**: an inference the app shows its reasons for. + +**Spelling:** + +- **seen**: in `docs/value-inventory.md` (a full scan of one Mac). +- **Apple key**: a key in Apple's System Information localization strings, + published in the macOS glossaries at + (files such as + `Apple_System_Profiler.lg` and `SP*Reporter.lg`). These glossaries come from + an older macOS release, so a key there is a spelling Apple used, not proof + that current macOS still reports it. +- **published**: in system_profiler output published online. +- **unconfirmed**: no source shows this spelling. It's matched so the value is + explained if it appears, and it's listed under "Values that need a scan" in + `PLAN.md`. + +## Applications and frameworks + +Sources: Apple Support, "Using Intel-based apps on a Mac with Apple silicon" +(), which names the Kind column's +Apple silicon, Intel, Universal and 32-bit kinds; Apple Developer News on +Rosetta after macOS 27 (); +CPython issue 137673 (), +which shows `arch_ios` reported for a Mac app without +`CFBundleSupportedPlatforms`; mondoohq/mql pull request 11104 +(), which shows `arch_other` for +an app whose main program is a shell script. + +| field | value | meaning | status | source | spelling | +|---|---|---|---|---|---| +| `arch_kind` | `arch_arm` | Apple silicon only | Normal; Info on an Intel Mac | Apple | seen | +| `arch_kind` | `arch_arm_i64` | Universal | Normal | Apple | seen | +| `arch_kind` | `arch_i64` | Intel only (Rosetta 2 on Apple silicon) | Info; Normal on an Intel Mac | Apple | seen | +| `arch_kind` | `arch_i32_i64` | Intel only, 32- and 64-bit | Info; Normal on an Intel Mac | Apple | Apple key | +| `arch_kind` | `arch_ios` | iPhone or iPad app | Info | Apple | seen | +| `arch_kind` | `arch_other` | not a kind System Information recognizes | Info | Inferred | seen | +| `arch_kind` | `arch_i32` | 32-bit Intel, can't run | Info | Apple | Apple key | +| `arch_kind` | `arch_ppc` | PowerPC, can't run | Info | Apple | unconfirmed | +| `obtained_from` | `apple` | Apple | Normal | Apple | Apple key, published | +| `obtained_from` | `mac_app_store` | Mac App Store | Normal | Apple | Apple key, published | +| `obtained_from` | `app_store` | App Store | Normal | Apple | Apple key | +| `obtained_from` | `ios_app_store` | App Store, as an iPhone or iPad app | Normal | Apple | unconfirmed | +| `obtained_from` | `identified_developer` | Developer ID | Normal | Apple | Apple key, published | +| `obtained_from` | `unknown` | no trusted signature | Info | Apple | Apple key, published | +| `private_framework` | `yes`, `no` | private or public framework | Info | Apple | seen | + +`obtained_from` is also explained for extensions. Its values aren't listed in +`docs/value-inventory.md` (the field was classed as free text), so their +spellings come from Apple's keys and published output, such as the samples in + (`resources/macos/system_profiler`). From faf65dbc3d4c38a860596ca90656bf465bad4f49 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:45:17 +0000 Subject: [PATCH 04/15] Explain every font kind and font flag value Font kinds (including unknown, a key in Apple's font reporter strings) and each on/off flag now say what the value means for this font, why it matters for documents and apps, and what to check in Font Book. --- PLAN.md | 2 +- .../Values/SoftwareArtifactValueRules.swift | 159 +++++++++++++++--- .../ValueCatalogTests.swift | 23 ++- docs/value-explanations.md | 24 +++ 4 files changed, 184 insertions(+), 24 deletions(-) diff --git a/PLAN.md b/PLAN.md index 9b259a1..c36b88b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -75,7 +75,7 @@ test run. 3. [x] Applications and frameworks (most rows in a scan): architecture (`arch_kind`), where it came from (`obtained_from`), and private frameworks. -4. [ ] Fonts (the most values in a scan): font kind, enabled, valid, +4. [x] Fonts (the most values in a scan): font kind, enabled, valid, duplicate, copy protected, embeddable, outline. 5. [ ] Extensions: loaded, loadable, dependencies, Intel code, architectures. diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift index e2b6e06..529aed1 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift @@ -244,33 +244,94 @@ func originExplanation(_ value: String) -> ValueExplanation? { // MARK: - Fonts, frameworks, extensions, and the Secure Element let softwareFlagValueRules: [ValueRule] = [ + // Font kinds are keys in Apple's SPFontReporter strings: truetype, opentype, + // postscript, bitmap, and unknown. On/off flags are reported as yes and no. ValueRule(.fonts, field: "type") { context in switch context.reportedValue.lowercased() { - case "truetype": .info("A TrueType font, a common scalable format on Macs and PCs.", confidence: .documented) - case "opentype": .info("An OpenType font, the current cross-platform scalable format.", confidence: .documented) - case "postscript": .info("A PostScript font, an older format that some newer apps no longer support.") - case "bitmap": .info("A bitmap font, drawn from fixed-size pixel images rather than scalable outlines.", confidence: .documented) - default: nil + case "truetype": + .info( + "A TrueType font, a common scalable format on Macs and PCs.", + detail: "Its characters are stored as TrueType outlines, which scale smoothly to any size.", + why: "Almost every app on Mac and Windows can use TrueType fonts.", + action: "Nothing to do.", + confidence: .documented + ) + case "opentype": + .info( + "An OpenType font, the current cross-platform scalable format.", + detail: "OpenType builds on TrueType and can hold extra typographic features, such as ligatures and alternate characters, and many languages in one file.", + why: "It's the most widely supported font format today, so documents that use it look the same on Macs and PCs.", + action: "Nothing to do.", + confidence: .documented + ) + case "postscript": + .info( + "A PostScript font, an older format that some newer apps no longer support.", + detail: "It's a PostScript Type 1 font, the format desktop publishing used before OpenType.", + why: "Several apps, including Adobe's since 2023, no longer support Type 1 fonts, so documents that use it may show a substitute font.", + action: "If an app can't use it, look for an OpenType version from the font's vendor." + ) + case "bitmap": + .info( + "A bitmap font, drawn from fixed-size pixel images rather than scalable outlines.", + detail: "Its characters are pixel images made for particular sizes.", + why: "It looks sharp only at the sizes it was made for, and blocky when enlarged or printed.", + action: "Nothing to do. If text looks blocky, choose an outline font instead.", + confidence: .documented + ) + case "unknown": + .info( + "macOS couldn't tell what format this font file uses.", + detail: "System Information didn't recognize the file as TrueType, OpenType, PostScript, or bitmap.", + why: "Apps may not be able to use a font whose format isn't recognized.", + action: "Open Font Book, select the font, and choose Validate Font to check the file.", + confidence: .documented + ) + default: + nil } }, ValueRule(.fonts, field: "enabled") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .normal("Turned on, so apps can use it.") - case false?: .info("Turned off in Font Book, so apps can't use it.", action: "Turn it back on in Font Book if you need it.") - case nil: nil + case true?: + .normal( + "Turned on, so apps can use it.", + detail: "It's active in Font Book, so it appears in apps' font menus.", + why: "Only fonts that are turned on can be used in documents.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "Turned off in Font Book, so apps can't use it.", + detail: "It's installed but turned off, so it doesn't appear in font menus. Documents that use it show a substitute font.", + why: "Turning off fonts you don't use keeps font menus short, but documents that need this font won't look as designed.", + action: "Turn it back on in Font Book if you need it.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.fonts, field: "valid") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .normal("The font file passed macOS's checks.") + .normal( + "The font file passed macOS's checks.", + detail: "macOS checked the file's structure and found no problems.", + why: "A valid font displays and prints as its designer intended.", + action: "Nothing to do.", + confidence: .documented + ) case false?: .info( "macOS found a problem in this font file.", - detail: "A damaged font can display incorrectly or make some apps behave unexpectedly.", - action: "Open Font Book, select the font, and choose Validate Font to see the problem." + detail: "The file's structure didn't pass macOS's checks, for example because it's damaged or incomplete.", + why: "A damaged font can display incorrectly or make some apps behave unexpectedly.", + action: "Open Font Book, select the font, and choose Validate Font to see the problem.", + confidence: .documented ) case nil: nil @@ -282,10 +343,19 @@ let softwareFlagValueRules: [ValueRule] = [ case true?: .info( "Another copy of this typeface is installed.", - action: "Font Book can find duplicates and turn off or remove the extra copies." + detail: "More than one font file provides this typeface, for example an older and a newer version.", + why: "Apps may use either copy, so text can look slightly different from one app or document to another.", + action: "Font Book can find duplicates and turn off or remove the extra copies.", + confidence: .documented ) case false?: - .normal("No other copy of this typeface is installed.") + .normal( + "No other copy of this typeface is installed.", + detail: "Only one font file provides this typeface.", + why: "Every app uses the same version of it.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -293,25 +363,70 @@ let softwareFlagValueRules: [ValueRule] = [ ValueRule(.fonts, field: "copy_protected") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .info("The font is marked copy-protected, so some apps won't copy or export it.") - case false?: .normal("The font isn't copy-protected.") - case nil: nil + case true?: + .info( + "The font is marked copy-protected, so some apps won't copy or export it.", + detail: "Its maker set a flag asking apps not to copy the font's data.", + why: "Apps that honor the flag may refuse to include it in PDFs or exported files, so documents can look different elsewhere.", + action: "If you share documents that use it, check the font's license, or use another font.", + confidence: .documented + ) + case false?: + .normal( + "The font isn't copy-protected.", + detail: "Its maker didn't set a flag restricting copying.", + why: "Apps can include it when exporting documents, as far as its license allows.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.fonts, field: "embeddable") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .info("Its license lets apps embed it in documents such as PDFs, so they look the same on other devices.") - case false?: .info("Its license doesn't allow embedding in documents, so a PDF may show another font on other devices.") - case nil: nil + case true?: + .info( + "Its license lets apps embed it in documents such as PDFs, so they look the same on other devices.", + detail: "The font's embedding permission allows apps to include it in the files they create.", + why: "People who open your documents see this font even if they don't have it installed.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "Its license doesn't allow embedding in documents, so a PDF may show another font on other devices.", + detail: "The font's embedding permission tells apps not to include it in the files they create.", + why: "Documents you share can look different for people who don't have this font installed.", + action: "For documents you share, choose a font that allows embedding.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.fonts, field: "outline") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .info("An outline font, which stays sharp at any size.", confidence: .documented) - case false?: .info("Not an outline font: it's drawn from fixed-size bitmaps, which can look blocky when enlarged.", confidence: .documented) - case nil: nil + case true?: + .info( + "An outline font, which stays sharp at any size.", + detail: "Its characters are stored as outlines that are drawn at whatever size is needed.", + why: "Text stays crisp on screen and in print, at any size.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "Not an outline font: it's drawn from fixed-size bitmaps, which can look blocky when enlarged.", + detail: "Its characters are stored as pixel images made for particular sizes.", + why: "It looks sharp only at the sizes it was made for.", + action: "Nothing to do. For large or printed text, choose an outline font.", + confidence: .documented + ) + case nil: + nil } }, diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index 7609af1..5dc951f 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -48,7 +48,7 @@ let appleSiliconReport: ValueReportContext = ValueReportContext(usbDeviceNames: let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, processor: .intel) /// Every value the app explains for fields with a limited set of values. -let explainedValueSamples: [ValueSample] = applicationValueSamples +let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -76,6 +76,27 @@ private let applicationValueSamples: [ValueSample] = { return samples }() +private let fontValueSamples: [ValueSample] = { + var samples: [ValueSample] = ["truetype", "opentype", "postscript", "bitmap", "unknown"].map { + ValueSample(.fonts, ["type"], $0) + } + + for flag in ["enabled", "valid"] { + for value in ["yes", "no"] { + samples.append(ValueSample(.fonts, [flag], value)) + samples.append(ValueSample(.fonts, ["typefaces", "[]", flag], value)) + } + } + + for flag in ["duplicate", "copy_protected", "embeddable", "outline"] { + for value in ["yes", "no"] { + samples.append(ValueSample(.fonts, ["typefaces", "[]", flag], value)) + } + } + + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { diff --git a/docs/value-explanations.md b/docs/value-explanations.md index 958804d..d7c6a33 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -70,3 +70,27 @@ an app whose main program is a shell script. `docs/value-inventory.md` (the field was classed as free text), so their spellings come from Apple's keys and published output, such as the samples in (`resources/macos/system_profiler`). + +## Fonts + +Sources: the font kinds and yes/no values are keys in Apple's +`SPFontReporter` strings; Font Book's Validate Font and duplicate handling are +described in the Font Book User Guide (). +Adobe ended support for PostScript Type 1 fonts in January 2023 +(). + +| field | value | meaning | status | source | spelling | +|---|---|---|---|---|---| +| `type` | `truetype` | TrueType | Info | Apple | seen | +| `type` | `opentype` | OpenType | Info | Apple | seen | +| `type` | `postscript` | PostScript Type 1 | Info | Standard | seen | +| `type` | `bitmap` | bitmap | Info | Apple | seen | +| `type` | `unknown` | unrecognized format | Info | Apple | Apple key | +| `enabled` | `yes`, `no` | turned on or off in Font Book | Normal, Info | Apple | seen (`yes`), Apple key (`no`) | +| `valid` | `yes`, `no` | passed or failed macOS's checks | Normal, Info | Apple | Apple key | +| `duplicate` | `yes`, `no` | another copy is installed | Info, Normal | Apple | seen (`no`), Apple key (`yes`) | +| `copy_protected` | `yes`, `no` | marked copy-protected | Info, Normal | Apple | seen (`no`), Apple key (`yes`) | +| `embeddable` | `yes`, `no` | license allows embedding | Info | Apple | seen (`yes`), Apple key (`no`) | +| `outline` | `yes`, `no` | outline or bitmap characters | Info | Apple | seen | + +`enabled` and `valid` are explained both for the font file and for each typeface. From d8f0084fbc699443c3fac560eae2f547041c47d2 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:46:59 +0000 Subject: [PATCH 05/15] Explain every extension value Extension rules move to their own file and each value now has every part. spext_incomplete, which Apple's extension strings list as the other dependency state, is recognized and worth a look instead of falling back to not yet explained. Missing Intel code is called out only on an Intel Mac, where it stops the extension from loading. The older kind, origin, and notarization fields from Apple's strings are explained too. --- PLAN.md | 2 +- .../project.pbxproj | 4 + .../Values/ExtensionValueRules.swift | 241 ++++++++++++++++++ .../Values/SoftwareArtifactValueRules.swift | 48 ---- .../Values/ValueExplanation.swift | 2 +- .../ValueCatalogTests.swift | 58 ++++- docs/value-explanations.md | 23 ++ 7 files changed, 327 insertions(+), 51 deletions(-) create mode 100644 SystemProfilerExplorer/Core/Explanations/Values/ExtensionValueRules.swift diff --git a/PLAN.md b/PLAN.md index c36b88b..99340ca 100644 --- a/PLAN.md +++ b/PLAN.md @@ -77,7 +77,7 @@ test run. frameworks. 4. [x] Fonts (the most values in a scan): font kind, enabled, valid, duplicate, copy protected, embeddable, outline. -5. [ ] Extensions: loaded, loadable, dependencies, Intel code, +5. [x] Extensions: loaded, loadable, dependencies, Intel code, architectures. 6. [ ] Network services and locations: hardware, service type, IPv4 and IPv6 configuration, proxies, VPN On Demand and its rules, PPP and VPN diff --git a/SystemProfilerExplorer.xcodeproj/project.pbxproj b/SystemProfilerExplorer.xcodeproj/project.pbxproj index c0b96b9..db14949 100644 --- a/SystemProfilerExplorer.xcodeproj/project.pbxproj +++ b/SystemProfilerExplorer.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ D0EB1B42212523FE20AEC875 /* ValuePanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 782B45221FBF672C566ABAC4 /* ValuePanel.swift */; }; B2C53C0C964281379BF8AEB3 /* ValuePanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */; }; B89D0EC021ABEEDFA1DE36B0 /* ValueCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A841B78A28F111C12390310A /* ValueCatalogTests.swift */; }; + CD175DDDEB38931B4B1ECC72 /* ExtensionValueRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9DE5CBC6A8A8C3941E11BF2 /* ExtensionValueRules.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -218,6 +219,7 @@ 782B45221FBF672C566ABAC4 /* ValuePanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePanel.swift; sourceTree = ""; }; C16CF4E58F995FDE34AE3D09 /* ValuePanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValuePanelTests.swift; sourceTree = ""; }; A841B78A28F111C12390310A /* ValueCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValueCatalogTests.swift; sourceTree = ""; }; + D9DE5CBC6A8A8C3941E11BF2 /* ExtensionValueRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionValueRules.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -232,6 +234,7 @@ 1E0A2857944678A12537BEB0 /* Values */ = { isa = PBXGroup; children = ( + D9DE5CBC6A8A8C3941E11BF2 /* ExtensionValueRules.swift */, B506E008FC2152F9DE79F941 /* DisplayAndMediaValueRules.swift */, CB0F841CB408C6BFDC2D9920 /* NetworkValueRules.swift */, 7E1F7A4C602522D7D94BEB84 /* PowerAndStorageValueRules.swift */, @@ -640,6 +643,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + CD175DDDEB38931B4B1ECC72 /* ExtensionValueRules.swift in Sources */, D0EB1B42212523FE20AEC875 /* ValuePanel.swift in Sources */, 329EAAA4FAD74DC9148A730D /* AccessibilityExplanations.swift in Sources */, 1225F9FD5B4A1D5480726182 /* AdditionalDataTypeExplanations.swift in Sources */, diff --git a/SystemProfilerExplorer/Core/Explanations/Values/ExtensionValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/ExtensionValueRules.swift new file mode 100644 index 0000000..65d031b --- /dev/null +++ b/SystemProfilerExplorer/Core/Explanations/Values/ExtensionValueRules.swift @@ -0,0 +1,241 @@ +import Foundation + +// MARK: - Extensions + +// Sources: the values seen in docs/value-inventory.md (spext_yes, spext_satisfied, +// spext_no, yes, arm64e) and the keys in Apple's SPExtensionsReporter strings: +// spext_yes, spext_no, spext_satisfied, spext_incomplete, spext_apple, +// spext_identified_developer, spext_unknown, spext_not_signed, spext_notarized, +// spext_arch_arm, spext_arch_x86, spext_arch_ppc, and spext_universal. Architecture +// names are the standard Mach-O names. + +let extensionValueRules: [ValueRule] = [ + ValueRule(.extensions, field: "spext_loaded") { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "This extension was loaded and running when the scan ran.", + detail: "macOS had loaded its code when System Information collected this report.", + why: "Loaded extensions run with high privileges, so they can affect the whole Mac's stability and security.", + action: "Nothing to do if you recognize it. If a third-party extension is unfamiliar, check which app or driver installed it.", + confidence: .documented + ) + case false?: + .info( + "Installed, but not loaded when the scan ran.", + detail: "The extension is on disk, but its code wasn't running. macOS loads many extensions only when the hardware or feature that needs them is in use.", + why: "An extension that isn't loaded has no effect until something needs it.", + action: "Nothing to do. If a device that needs it doesn't work, check whether it's waiting for approval in System Settings › Privacy & Security.", + confidence: .documented + ) + case nil: + nil + } + }, + + ValueRule(.extensions, field: "spext_hasAllDependencies") { context in + switch tokenSuffix(context.reportedValue, after: "spext_") ?? context.reportedValue.lowercased() { + case "satisfied", "yes": + .normal( + "Everything this extension depends on is installed.", + detail: "Every other extension it needs in order to load is present.", + why: "It can load whenever the feature or device that needs it is used.", + action: "Nothing to do.", + confidence: .documented + ) + case "incomplete", "no": + .review( + "Something this extension depends on is missing, so it may not load.", + detail: "One or more extensions it needs aren't installed or couldn't be found.", + why: "The device or feature it supports may stop working.", + action: "Update or reinstall the app or driver that installed it, or remove it if you no longer use it.", + confidence: .documented + ) + default: + nil + } + }, + + ValueRule(.extensions, field: "spext_has64BitIntelCode") { context in + intelCodeExplanation(context.reportedValue, processor: context.report.processor) + }, + + ValueRule(.extensions, field: "spext_loadable") { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .normal( + "macOS can load this extension.", + detail: "It's built for this kind of Mac, and nothing in the security policy stops it from loading.", + why: "It can run when the device or feature that needs it is used.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "macOS reports that this extension can't be loaded here.", + detail: "Common reasons are that it wasn't built for this Mac, it hasn't been approved, or the startup security policy doesn't allow it.", + why: "The device or feature that needs it won't work until it can load.", + action: "If you use the hardware or app it belongs to, check for an update and approve it in System Settings › Privacy & Security if asked. Otherwise, you can remove the app that installed it.", + confidence: .documented + ) + case nil: + nil + } + }, + + ValueRule(.extensions, field: "spext_architectures") { context in + extensionArchitectureExplanation(context.reportedValue) + }, + + ValueRule(.extensions, field: "spext_runtime_environment") { context in + extensionKindExplanation(context.reportedValue) + }, + + ValueRule(.extensions, field: "spext_obtained_from") { context in + let origin: String = tokenSuffix(context.reportedValue, after: "spext_") ?? context.reportedValue + + if origin == "not_signed" { + return .info( + "The extension isn't signed.", + detail: "It has no code signature, so macOS can't confirm who made it.", + why: "Current macOS won't load an unsigned kernel extension, and unsigned code can't be checked for changes.", + action: "Remove it unless you know where it came from, for example from your own development work.", + confidence: .documented + ) + } + + return originExplanation(origin) + }, + + ValueRule(.extensions, field: "spext_notarized") { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .normal( + "Notarized: Apple checked this extension for malware before it was distributed.", + detail: "Its developer submitted it to Apple, and Apple's automated check found no known malware.", + why: "Gatekeeper expects software from outside the App Store to be notarized.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "Not notarized.", + detail: "Apple hasn't checked this version for malware. Apple's own extensions and older third-party ones often aren't notarized.", + why: "Notarization is one of the checks that tells you software from outside the App Store came from its developer unchanged.", + action: "If it's a third-party extension you don't recognize, check which app installed it.", + confidence: .documented + ) + case nil: + nil + } + } +] + +private func intelCodeExplanation(_ value: String, processor: MacProcessorFamily?) -> ValueExplanation? { + switch (decodeBooleanLike(value), processor) { + case (true?, _): + .info( + "Includes code for Intel Macs.", + detail: "The extension contains 64-bit Intel (x86_64) code.", + why: "It can load on Intel Macs. On a Mac with Apple silicon, only its Apple silicon code is used.", + action: "Nothing to do.", + confidence: .documented + ) + case (false?, .intel?): + .info( + "Doesn't include code for Intel Macs, and this Mac has an Intel processor.", + detail: "Without 64-bit Intel code, the extension can't load on this Mac.", + why: "The device or feature that needs it won't work here.", + action: "Check for a version of the extension, or of the app that installed it, that supports Intel Macs.", + confidence: .documented + ) + case (false?, _): + .info( + "Doesn't include code for Intel Macs.", + detail: "The extension has no 64-bit Intel code, so it's built only for Macs with Apple silicon.", + why: "It can't load on an Intel Mac, which only matters if you move it to one.", + action: "Nothing to do on a Mac with Apple silicon.", + confidence: .documented + ) + case (nil, _): + nil + } +} + +func extensionArchitectureExplanation(_ value: String) -> ValueExplanation? { + switch value.lowercased() { + case "arm64e": + .info( + "Contains code for Apple silicon (arm64e), which kernel extensions need on those Macs.", + detail: "arm64e is Apple silicon code that uses pointer authentication.", + why: "Kernel extensions on a Mac with Apple silicon must include arm64e code to load.", + action: "Nothing to do.", + confidence: .documented + ) + case "arm64": + .info( + "Contains code for Apple silicon (arm64).", + detail: "arm64 is Apple silicon code without pointer authentication.", + why: "It's enough for most system extensions, but kernel extensions on Apple silicon need arm64e.", + action: "Nothing to do unless the extension fails to load. Then check for an update.", + confidence: .documented + ) + case "x86_64": + .info( + "Contains code for Intel Macs (x86_64).", + detail: "x86_64 is 64-bit Intel code.", + why: "It lets the extension load on Intel Macs. On a Mac with Apple silicon this part isn't used.", + action: "Nothing to do.", + confidence: .documented + ) + case "i386": + .info( + "Contains 32-bit Intel code, which current macOS can't run.", + detail: "i386 is 32-bit Intel code. macOS stopped loading 32-bit code in macOS Catalina (10.15).", + why: "This part of the extension is never used. If it's the only architecture listed, the extension can't load.", + action: "If this is its only architecture, update or remove the extension.", + confidence: .documented + ) + default: + nil + } +} + +private func extensionKindExplanation(_ value: String) -> ValueExplanation? { + switch tokenSuffix(value, after: "spext_") ?? value { + case "arch_arm": + .info( + "Built for Apple silicon.", + detail: "The extension contains Apple silicon code only.", + why: "It loads natively on a Mac with Apple silicon, but not on an Intel Mac.", + action: "Nothing to do on a Mac with Apple silicon.", + confidence: .documented + ) + case "arch_x86": + .info( + "Built for Intel Macs.", + detail: "The extension contains Intel code only.", + why: "Rosetta 2 can't translate kernel code, so an Intel-only kernel extension can't load on a Mac with Apple silicon.", + action: "On a Mac with Apple silicon, check for an updated version from its developer.", + confidence: .documented + ) + case "universal": + .normal( + "Universal: contains code for both Apple silicon and Intel Macs.", + detail: "The extension includes a version for each kind of processor.", + why: "It can load on either kind of Mac.", + action: "Nothing to do.", + confidence: .documented + ) + case "arch_ppc": + .info( + "Built for PowerPC processors, which no current Mac can run.", + detail: "The extension contains PowerPC code only.", + why: "It can't load on this Mac.", + action: "Remove it, or the app that installed it, if you no longer need it.", + confidence: .documented + ) + default: + nil + } +} diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift index 529aed1..e1e6bd0 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift @@ -24,30 +24,6 @@ let softwareArtifactValueRules: [ValueRule] = [ default: nil } - }, - - ValueRule(.extensions, field: "spext_loaded") { context in - switch decodeBooleanLike(context.reportedValue) { - case true?: .info("This extension was loaded and running when the scan ran.") - case false?: .info("Installed, but not loaded when the scan ran.") - case nil: nil - } - }, - - ValueRule(.extensions, field: "spext_hasAllDependencies") { context in - switch decodeBooleanLike(context.reportedValue) { - case true?: .normal("Everything this extension depends on is installed.") - case false?: .review("Something this extension depends on is missing, so it may not load.") - case nil: nil - } - }, - - ValueRule(.extensions, field: "spext_has64BitIntelCode") { context in - switch decodeBooleanLike(context.reportedValue) { - case true?: .info("Includes code for Intel Macs.") - case false?: .info("Doesn't include code for Intel Macs.") - case nil: nil - } } ] @@ -453,30 +429,6 @@ let softwareFlagValueRules: [ValueRule] = [ } }, - ValueRule(.extensions, field: "spext_loadable") { context in - switch decodeBooleanLike(context.reportedValue) { - case true?: - .normal("macOS can load this extension.") - case false?: - .info( - "macOS reports that this extension can't be loaded here.", - detail: "Common reasons are that it wasn't built for this Mac, it hasn't been approved, or the startup security policy doesn't allow it." - ) - case nil: - nil - } - }, - - ValueRule(.extensions, field: "spext_architectures") { context in - switch context.reportedValue.lowercased() { - case "arm64e": .info("Contains code for Apple silicon (arm64e), which kernel extensions need on those Macs.", confidence: .documented) - case "arm64": .info("Contains code for Apple silicon (arm64).", confidence: .documented) - case "x86_64": .info("Contains code for Intel Macs (x86_64).", confidence: .documented) - case "i386": .info("Contains 32-bit Intel code, which current macOS can't run.", confidence: .documented) - default: nil - } - }, - ValueRule(.secureElement, field: "se_in_restricted_mode") { context in switch decodeBooleanLike(context.reportedValue) { case false?: diff --git a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift index acee9fd..74d3425 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/ValueExplanation.swift @@ -325,7 +325,7 @@ private let valueRuleIndex: [ValueRuleKey: [ValueRule]] = { + networkValueRules + ethernetValueRules + wifiValueRules + bluetoothValueRules + displayValueRules + audioValueRules + thunderboltValueRules + legacySoftwareValueRules + syncServicesValueRules + internationalValueRules + accessibilityValueRules - + nvmeValueRules + configurationProfileValueRules + printerValueRules + + nvmeValueRules + configurationProfileValueRules + printerValueRules + extensionValueRules + vendorIdentifierValueRules + thirdWaveValueRules var index: [ValueRuleKey: [ValueRule]] = [:] diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index 5dc951f..df61a24 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -48,7 +48,7 @@ let appleSiliconReport: ValueReportContext = ValueReportContext(usbDeviceNames: let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, processor: .intel) /// Every value the app explains for fields with a limited set of values. -let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples +let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -97,6 +97,30 @@ private let fontValueSamples: [ValueSample] = { return samples }() +private let extensionValueSamples: [ValueSample] = { + var samples: [ValueSample] = [] + + for value in ["spext_yes", "spext_no"] { + samples.append(ValueSample(.extensions, ["spext_loaded"], value)) + samples.append(ValueSample(.extensions, ["spext_notarized"], value)) + + for report in [ValueReportContext.empty, appleSiliconReport, intelReport] { + samples.append(ValueSample(.extensions, ["spext_has64BitIntelCode"], value, report: report)) + } + } + + samples += ["spext_satisfied", "spext_incomplete"].map { ValueSample(.extensions, ["spext_hasAllDependencies"], $0) } + samples += ["yes", "no"].map { ValueSample(.extensions, ["spext_loadable"], $0) } + samples += ["arm64e", "arm64", "x86_64", "i386"].map { ValueSample(.extensions, ["spext_architectures", "[]"], $0) } + samples += ["spext_arch_arm", "spext_arch_x86", "spext_universal", "spext_arch_ppc"].map { + ValueSample(.extensions, ["spext_runtime_environment"], $0) + } + samples += ["spext_apple", "spext_identified_developer", "spext_unknown", "spext_not_signed"].map { + ValueSample(.extensions, ["spext_obtained_from"], $0) + } + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { @@ -155,6 +179,38 @@ struct ValueCatalogTests { #expect(valueExplanation(dataType: .applications, path: ["obtained_from"], scalar: .string("somewhere_else"))?.status == .unknown) } + // MARK: - Extensions + + @Test + func missingDependenciesAreWorthALook() { + func status(_ value: String) -> ValueStatus? { + valueExplanation(dataType: .extensions, path: ["spext_hasAllDependencies"], scalar: .string(value))?.status + } + + #expect(status("spext_satisfied") == .normal) + #expect(status("spext_incomplete") == .worthReviewing) + #expect(status("spext_partly") == .unknown) + } + + @Test + func intelOnlyGapMattersOnlyOnAnIntelMac() throws { + let onIntel = try #require(valueExplanation( + dataType: .extensions, + path: ["spext_has64BitIntelCode"], + scalar: .string("spext_no"), + report: intelReport + )) + let onAppleSilicon = try #require(valueExplanation( + dataType: .extensions, + path: ["spext_has64BitIntelCode"], + scalar: .string("spext_no"), + report: appleSiliconReport + )) + + #expect(onIntel.detail?.contains("can't load on this Mac") == true) + #expect(onAppleSilicon.detail?.contains("only for Macs with Apple silicon") == true) + } + @Test func processorFamilyComesFromTheHardwareOverview() { let appleSilicon: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "chip_type": .string("Apple M1")])] diff --git a/docs/value-explanations.md b/docs/value-explanations.md index d7c6a33..d4c7727 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -94,3 +94,26 @@ Adobe ended support for PostScript Type 1 fonts in January 2023 | `outline` | `yes`, `no` | outline or bitmap characters | Info | Apple | seen | `enabled` and `valid` are explained both for the font file and for each typeface. + +## Extensions + +Sources: the values seen in `docs/value-inventory.md` and the keys in Apple's +`SPExtensionsReporter` strings. Apple documents that kernel extensions on +Apple silicon must be built for arm64e and that Rosetta 2 can't translate +kernel extensions (), +and describes notarization in +. +`spext_runtime_environment`, `spext_obtained_from`, and `spext_notarized` are +Apple keys that current macOS may no longer report. + +| field | value | meaning | status | source | spelling | +|---|---|---|---|---|---| +| `spext_loaded` | `spext_yes`, `spext_no` | loaded or not when scanned | Info | Apple | seen (`spext_yes`), Apple key | +| `spext_hasAllDependencies` | `spext_satisfied` | dependencies present | Normal | Apple | seen | +| `spext_hasAllDependencies` | `spext_incomplete` | a dependency is missing | Worth a look | Apple | Apple key | +| `spext_has64BitIntelCode` | `spext_yes`, `spext_no` | has Intel code | Info (says it can't load when an Intel Mac lacks it) | Apple | seen (`spext_no`), Apple key | +| `spext_loadable` | `yes`, `no` | can or can't load | Normal, Info | Apple | seen (`yes`) | +| `spext_architectures` | `arm64e`, `arm64`, `x86_64`, `i386` | code it contains | Info | Apple | seen (`arm64e`), standard names | +| `spext_runtime_environment` | `spext_arch_arm`, `spext_arch_x86`, `spext_universal`, `spext_arch_ppc` | kind | Info, Normal (Universal) | Apple | Apple key | +| `spext_obtained_from` | `spext_apple`, `spext_identified_developer`, `spext_unknown`, `spext_not_signed` | where it came from | as for apps; Info for not signed | Apple | Apple key | +| `spext_notarized` | `spext_yes`, `spext_no` | notarized or not | Normal, Info | Apple | Apple key | From f628cc969f95df2b5e53e59d0492ef8547b1c190 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:51:44 +0000 Subject: [PATCH 06/15] Explain every network service and network location value Service hardware and types, IPv4 and IPv6 configuration methods, proxy switches, VPN On Demand and its rules, the PPP and VPN connection switches, Wi-Fi join modes, VPN sign-in methods, Ethernet media, active locations, and automounted volumes now have every part of an explanation. PPP services tell PPPoE, L2TP, and PPTP apart, and the System Configuration values that weren't in the inventory, such as Bond, VLAN, BOOTP, and RouterAdvertisement, are recognized. --- PLAN.md | 2 +- .../Values/NetworkValueRules.swift | 781 +++++++++++++++--- .../ValueCatalogTests.swift | 99 +++ docs/value-explanations.md | 48 ++ 4 files changed, 829 insertions(+), 101 deletions(-) diff --git a/PLAN.md b/PLAN.md index 99340ca..7b5bd1b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -79,7 +79,7 @@ test run. duplicate, copy protected, embeddable, outline. 5. [x] Extensions: loaded, loadable, dependencies, Intel code, architectures. -6. [ ] Network services and locations: hardware, service type, IPv4 and IPv6 +6. [x] Network services and locations: hardware, service type, IPv4 and IPv6 configuration, proxies, VPN On Demand and its rules, PPP and VPN switches, Wi-Fi join mode, VPN sign-in, Ethernet media, active location, network volumes. diff --git a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift index fa19ba0..6ec2630 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift @@ -2,15 +2,40 @@ import Foundation // MARK: - Network services +// Sources: hardware and service types are System Configuration constants +// (SCSchemaDefinitions.h and SCNetworkConfiguration.h): Ethernet, AirPort, FireWire and +// Modem for hardware; Ethernet, IEEE80211, Bridge, Bond, VLAN, 6to4, IPSec, PPP and VPN +// for service types, with PPP subtypes PPPSerial, PPPoE, L2TP and PPTP. Configuration +// methods are the IPv4 and IPv6 ConfigMethod constants. The values in +// docs/value-inventory.md confirm the spellings used in reports. + let networkValueRules: [ValueRule] = [ ValueRule(.network, .networkLocation, field: "hardware") { context in switch context.reportedValue { case "Ethernet": .normal( - "A wired-style connection: a built-in Ethernet port, a USB or Thunderbolt adapter, a Thunderbolt Bridge, or iPhone USB tethering." + "A wired-style connection: a built-in Ethernet port, a USB or Thunderbolt adapter, a Thunderbolt Bridge, or iPhone USB tethering.", + detail: "macOS uses the Ethernet type for any interface that behaves like a network port, not only a physical Ethernet socket.", + why: "Wired connections are usually faster and steadier than Wi-Fi.", + action: "Nothing to do. The service's name and interface say which port or adapter it is.", + confidence: .documented ) case "AirPort": - .normal("Wi-Fi. “AirPort” is the name macOS uses internally for Wi-Fi.", confidence: .documented) + .normal( + "Wi-Fi. “AirPort” is the name macOS uses internally for Wi-Fi.", + detail: "This service uses the Mac's Wi-Fi hardware; the name comes from Apple's original Wi-Fi products.", + why: "The Wi-Fi network the Mac joins and its security apply to this service.", + action: "Nothing to do.", + confidence: .documented + ) + case "FireWire": + .info( + "Networking over FireWire, used to connect older Macs with a FireWire cable.", + detail: "Only older Macs have FireWire ports. The service carries traffic only while a FireWire cable connects two computers.", + why: "It does nothing on its own, and it isn't used for internet access.", + action: "Nothing to do. You can remove the service in System Settings › Network if you never use it.", + confidence: .documented + ) case "Modem": modemServiceExplanation(context) default: @@ -19,50 +44,150 @@ let networkValueRules: [ValueRule] = [ }, ValueRule(.network, .networkLocation, field: "type") { context in - let value: String = context.reportedValue + serviceTypeExplanation(context.reportedValue) + }, - if value == "Ethernet" { - return .normal("An Ethernet network service.") - } + ValueRule(.network, .networkLocation, field: "ConfigMethod") { context in + ipConfigurationExplanation(context.reportedValue, family: context.parentKey) + } +] - if value == "AirPort" || value == "IEEE80211" { - return .normal("A Wi-Fi network service.", confidence: .documented) - } +private let vpnWhy: String = + "Traffic goes through the VPN only while it's connected, and whoever runs the VPN server can see the traffic it carries." - if value == "Bridge" { - return .info("A network bridge, such as Thunderbolt Bridge, which connects Macs directly over a cable.") - } +/// Explains a network service type such as `Ethernet`, `PPP (PPPSerial)`, or `VPN (com.example.vpn)`. +func serviceTypeExplanation(_ value: String) -> ValueExplanation? { + switch value { + case "Ethernet": + return .normal( + "An Ethernet network service.", + detail: "It carries traffic over an Ethernet-type interface: a port, an adapter, or a USB link that behaves like one.", + why: "Wired connections are usually faster and steadier than Wi-Fi.", + action: "Nothing to do.", + confidence: .documented + ) + case "AirPort", "IEEE80211": + return .normal( + "A Wi-Fi network service.", + detail: "It carries traffic over the Mac's Wi-Fi hardware. macOS names Wi-Fi “AirPort” or “IEEE80211” internally.", + why: "Wi-Fi settings, such as which networks the Mac joins, belong to this service.", + action: "Nothing to do.", + confidence: .documented + ) + case "Bridge": + return .info( + "A network bridge, such as Thunderbolt Bridge, which connects Macs directly over a cable.", + detail: "A bridge joins several interfaces into one network. macOS creates Thunderbolt Bridge on Macs with Thunderbolt ports.", + why: "It lets two Macs connected by a Thunderbolt cable share files quickly without a router. It doesn't affect internet access.", + action: "Nothing to do.", + confidence: .documented + ) + case "Bond": + return .info( + "A link aggregate (bond) that combines several Ethernet ports into one connection.", + detail: "Traffic is spread across the combined ports, and the connection keeps working if one of them fails.", + why: "Bonds are set up on purpose, usually on servers, for speed or redundancy.", + action: "Nothing to do if you set it up. Otherwise, review it under Manage Virtual Interfaces in System Settings › Network.", + confidence: .documented + ) + case "VLAN": + return .info( + "A virtual LAN (VLAN) on an Ethernet port.", + detail: "It tags traffic so it joins a separate logical network over the same cable.", + why: "VLANs are set up on purpose, usually on managed networks.", + action: "Nothing to do if your network uses VLANs. Otherwise, review it under Manage Virtual Interfaces in System Settings › Network.", + confidence: .documented + ) + case "6to4": + return .info( + "A 6to4 tunnel that carries IPv6 traffic over IPv4.", + detail: "6to4 was a way to reach IPv6 sites before internet providers offered IPv6 directly.", + why: "6to4 is obsolete and rarely works today, so it's usually a leftover setting.", + action: "Remove the service in System Settings › Network unless you set it up on purpose.", + confidence: .documented + ) + case "IPSec": + return .info( + "An IPSec VPN service (Cisco IPSec).", + detail: "macOS's built-in VPN client uses this service to connect to an IPSec VPN server.", + why: vpnWhy, + action: "Nothing to do if you use this VPN. If you don't recognize it, review it in System Settings › VPN.", + confidence: .documented + ) + default: + break + } - if value.hasPrefix("PPP") { - return .info( - "A dial-up style connection (PPP)\(value.contains("Serial") ? " over a serial port" : "").", - detail: "macOS creates these for modems and for USB devices that present a serial port." - ) - } + if value.hasPrefix("PPP") { + return pppServiceExplanation(subtype: parenthesizedPart(of: value)) + } - if value.hasPrefix("VPN") { - let provider: String? = value - .split(separator: "(", maxSplits: 1) - .dropFirst() - .first - .map { String($0.dropLast(value.hasSuffix(")") ? 1 : 0)) } + if value.hasPrefix("VPN") { + let provider: String? = parenthesizedPart(of: value) - return .info( - "A VPN service\(provider.map { " provided by the app \($0)" } ?? "").", - detail: "The VPN app adds this service. It routes traffic through the VPN only while it's connected." - ) - } + return .info( + "A VPN service\(provider.map { " provided by the app \($0)" } ?? "").", + detail: "The VPN app adds this service. It routes traffic through the VPN only while it's connected.", + why: vpnWhy, + action: "Nothing to do if you use this VPN. If you don't recognize it, check the app it names and remove the VPN in System Settings › VPN.", + confidence: .documented + ) + } + return nil +} + +/// Returns the text inside the parentheses of values such as `PPP (PPPSerial)`. +private func parenthesizedPart(of value: String) -> String? { + guard let open = value.firstIndex(of: "("), value.hasSuffix(")") else { return nil - }, + } - ValueRule(.network, .networkLocation, field: "ConfigMethod") { context in - ipConfigurationExplanation(context.reportedValue, family: context.parentKey) + let inner: String = String(value[value.index(after: open).. ValueExplanation? { + switch subtype { + case "PPPSerial"?, nil: + .info( + "A dial-up style connection (PPP)\(subtype == nil ? "" : " over a serial port").", + detail: "macOS creates these for modems and for USB devices that present a serial port.", + why: "It carries traffic only while a connection is configured and started, so on its own it does nothing.", + action: "Nothing to do if you use it. If you don't, you can remove it in System Settings › Network.", + confidence: .documented + ) + case "PPPoE"?: + .info( + "A PPP over Ethernet (PPPoE) connection, used by some DSL and fiber providers.", + detail: "The Mac signs in to the internet provider itself over Ethernet, instead of leaving that to a router.", + why: "Internet access through this service depends on the provider's account name and password stored in it.", + action: "Nothing to do if your provider needs PPPoE on this Mac. In most homes the router handles it instead.", + confidence: .documented + ) + case "L2TP"?: + .info( + "An L2TP over IPSec VPN service.", + detail: "macOS's built-in VPN client uses this service to connect to an L2TP VPN server.", + why: vpnWhy, + action: "Nothing to do if you use this VPN. If you don't recognize it, review it in System Settings › VPN.", + confidence: .documented + ) + case "PPTP"?: + .info( + "A PPTP VPN service, an old VPN type macOS no longer supports.", + detail: "macOS removed its PPTP client in macOS Sierra, so this service can't connect.", + why: "PPTP's encryption can be broken, which is why Apple removed it.", + action: "Remove the service, and ask the VPN's administrator for a supported VPN type.", + confidence: .documented + ) + default: + nil } -] +} -/// Explains a `Modem` network service using the rest of its record and, when collected, -/// the USB device list. Most modern "modems" are USB serial devices such as dev boards. +/// Explains a network service with Modem hardware using the rest of its record and, when +/// collected, the USB device list. Most modern "modems" are USB serial devices such as dev boards. func modemServiceExplanation(_ context: ValueContext) -> ValueExplanation { let serviceName: String = context.sibling("_name") ?? "" let interface: String = context.sibling("interface") ?? "" @@ -83,7 +208,12 @@ func modemServiceExplanation(_ context: ValueContext) -> ValueExplanation { } guard !reasons.isEmpty else { - return .info("A modem-type network service, used for dial-up or serial connections.") + return .info( + "A modem-type network service, used for dial-up or serial connections.", + detail: "macOS adds a service like this for a phone-line modem, or for a device that presents itself as one.", + why: "It does nothing unless a connection is configured and started.", + action: "Nothing to do if you use it. Otherwise, you can remove the service in System Settings › Network." + ) } let summary: String = deviceFamily.map { @@ -104,6 +234,7 @@ func modemServiceExplanation(_ context: ValueContext) -> ValueExplanation { return .info( summary, detail: detail, + why: "It can't reach the internet by itself, and it sends no traffic unless a connection is set up for it.", action: "Nothing, if you use such a device. If you don't recognize it, you can remove the service in System Settings › Network.", confidence: .likely(reasons: reasons) ) @@ -141,33 +272,114 @@ private func usbDevice(named serviceName: String, in deviceNames: [String]) -> B return deviceNames.contains { $0.localizedCaseInsensitiveContains(firstWord) } } +private let dynamicAddressWhy: String = "The service has an address only while that connection is up." + func ipConfigurationExplanation(_ value: String, family: String?) -> ValueExplanation? { switch (family, value) { case ("IPv4", "DHCP"): - .normal("Gets its IP address automatically from the router (DHCP), the usual setup.", confidence: .documented) + .normal( + "Gets its IP address automatically from the router (DHCP), the usual setup.", + detail: "The router or another DHCP server gives this service its address, subnet, router, and usually its DNS servers.", + why: "This is how most home and office networks work, and it avoids two devices using the same address.", + action: "Nothing to do.", + confidence: .documented + ) case ("IPv4", "Manual"): .info( "The IP address was entered by hand.", detail: "That's intended on some networks. Elsewhere, a mistyped manual address can stop the connection from working.", + why: "A manual address has to match the network. If it doesn't, or another device uses it too, the connection fails.", action: "If this network should configure itself, choose Using DHCP in System Settings › Network.", confidence: .documented ) case ("IPv4", "INFORM"): - .info("Uses a manually entered IP address, with other settings from DHCP.", confidence: .documented) + .info( + "Uses a manually entered IP address, with other settings from DHCP.", + detail: "The address was typed in, and the network's DHCP server supplies the rest, such as DNS servers.", + why: "It's used when a device needs a fixed address but should still get other settings automatically.", + action: "Nothing to do if this address was assigned to this Mac.", + confidence: .documented + ) case ("IPv4", "BOOTP"): - .info("Gets its address from a BOOTP server, an older method used on some managed networks.", confidence: .documented) + .info( + "Gets its address from a BOOTP server, an older method used on some managed networks.", + detail: "BOOTP is the older protocol that DHCP replaced.", + why: "It's rare today. Outside a managed network that needs it, it's usually an old setting.", + action: "Unless your network needs BOOTP, choose Using DHCP in System Settings › Network.", + confidence: .documented + ) case ("IPv4", "LinkLocal"): - .info("Uses only a self-assigned 169.254 address, enough for devices on the same local network.") + .info( + "Uses only a self-assigned 169.254 address, enough for devices on the same local network.", + detail: "The service gives itself an address and doesn't ask a router for one.", + why: "It can reach devices on the same local network, but not the internet.", + action: "If you need internet access over this service, choose Using DHCP in System Settings › Network.", + confidence: .documented + ) + case ("IPv4", "Automatic"): + .normal( + "macOS chooses how to get the IP address automatically.", + detail: "macOS picks the method that fits the connection, usually DHCP.", + why: "It works on most networks without any setup.", + action: "Nothing to do.", + confidence: .documented + ) case ("IPv4", "PPP"): - .info("The IP address is assigned by the dial-up (PPP) connection.") + .info( + "The IP address is assigned by the dial-up (PPP) connection.", + detail: "The dial-up, PPPoE, or VPN connection gets its address when it connects.", + why: dynamicAddressWhy, + action: "Nothing to do.", + confidence: .documented + ) case ("IPv4", "VPN"): - .info("The IP address is assigned by the VPN.") + .info( + "The IP address is assigned by the VPN.", + detail: "The VPN server gives this service its address when the VPN connects.", + why: dynamicAddressWhy, + action: "Nothing to do.", + confidence: .documented + ) case ("IPv6", "Automatic"): - .normal("IPv6 is configured automatically, the default.", confidence: .documented) + .normal( + "IPv6 is configured automatically, the default.", + detail: "The Mac creates its IPv6 addresses from what the router announces, or gets them by DHCPv6.", + why: "The Mac uses IPv6 whenever the network offers it, with no setup.", + action: "Nothing to do.", + confidence: .documented + ) case ("IPv6", "LinkLocal"): - .info("IPv6 uses only a link-local address, for the local network.", confidence: .documented) + .info( + "IPv6 uses only a link-local address, for the local network.", + detail: "The service has only an fe80:: address, which works between devices on the same network.", + why: "It can't reach IPv6 sites on the internet. IPv4 still works if it's configured.", + action: "If your network offers IPv6, choose Automatically in System Settings › Network.", + confidence: .documented + ) case ("IPv6", "Manual"): - .info("The IPv6 address was entered by hand.", confidence: .documented) + .info( + "The IPv6 address was entered by hand.", + detail: "The IPv6 address, prefix length, and router were typed in.", + why: "A manual IPv6 address has to match the network. If it doesn't, IPv6 connections fail.", + action: "If this network should configure itself, choose Automatically in System Settings › Network.", + confidence: .documented + ) + case ("IPv6", "RouterAdvertisement"): + .normal( + "IPv6 is configured from the router's announcements.", + detail: "The Mac builds its IPv6 address from the prefix the router announces.", + why: "It works on most IPv6 networks without any setup.", + action: "Nothing to do.", + confidence: .documented + ) + case ("IPv6", "6to4"): + .info( + "IPv6 is tunneled over IPv4 (6to4).", + detail: "6to4 carried IPv6 traffic inside IPv4 before internet providers offered IPv6 directly.", + why: "6to4 is obsolete and rarely works today.", + action: "Choose Automatically in System Settings › Network unless you set up 6to4 on purpose.", + confidence: .documented + ) default: nil } @@ -593,6 +805,12 @@ private func bluetoothVendorExplanation(_ value: String) -> ValueExplanation? { // MARK: - Proxies and VPN On Demand +// Sources: proxy, VPN On Demand, PPP, AirPort join mode, and VPN authentication keys and +// values are System Configuration constants (SCSchemaDefinitions.h) and appear in +// docs/value-inventory.md or Apple's System Information strings. On Demand rule actions +// and interface types come from Apple's VPN On Demand documentation (Device Management, +// VPN.OnDemandRulesElement). + /// Reads on/off settings that network configuration stores as `yes`/`no`, `true`/`false`, /// or the numbers 1 and 0. func decodeSettingFlag(_ value: String) -> Bool? { @@ -619,6 +837,9 @@ private let proxyProtocols: [(field: String, traffic: String)] = [ private let proxySettingsAction: String = "If you didn't set up a proxy and your organization doesn't use one, check the Proxies settings for this service in System Settings › Network." +private let proxyWhy: String = + "A proxy can see, log, and filter the traffic it carries, so it should be one that you or your organization set up." + let proxyValueRules: [ValueRule] = proxyProtocols.map { proxy -> ValueRule in ValueRule(.network, .networkLocation, field: proxy.field) { context in switch decodeSettingFlag(context.reportedValue) { @@ -626,11 +847,18 @@ let proxyValueRules: [ValueRule] = proxyProtocols.map { proxy -> ValueRule in .info( "A proxy server is set for \(proxy.traffic) on this service.", detail: "Matching connections go through the proxy instead of straight to the destination. Organizations, schools, and some security or filtering apps set proxies.", + why: proxyWhy, action: proxySettingsAction, confidence: .documented ) case false?: - .normal("No proxy is set for \(proxy.traffic); connections go directly.", confidence: .documented) + .normal( + "No proxy is set for \(proxy.traffic); connections go directly.", + detail: "This kind of traffic goes straight to its destination, not through a proxy server.", + why: "That's the usual setup at home and on most networks.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -642,11 +870,18 @@ let proxyValueRules: [ValueRule] = proxyProtocols.map { proxy -> ValueRule in .info( "Proxy settings come from an automatic configuration (PAC) file.", detail: "The file decides, for each address, whether to use a proxy. Organizations often set this up.", + why: proxyWhy, action: proxySettingsAction, confidence: .documented ) case false?: - .normal("No automatic proxy configuration file is used.", confidence: .documented) + .normal( + "No automatic proxy configuration file is used.", + detail: "macOS doesn't load a PAC file to decide which connections use a proxy.", + why: "That's the usual setup outside organizations that manage their network.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -658,10 +893,18 @@ let proxyValueRules: [ValueRule] = proxyProtocols.map { proxy -> ValueRule in .info( "macOS looks for proxy settings published on the network (WPAD).", detail: "This is useful on managed networks. On other networks, it lets the network suggest a proxy.", + why: "Any network this Mac joins can then point its traffic at a proxy, which is only wanted on networks you trust.", + action: "If you don't use a network that needs it, turn off Auto Proxy Discovery in the service's Proxies settings.", confidence: .documented ) case false?: - .normal("macOS doesn't look for proxy settings on the network.", confidence: .documented) + .normal( + "macOS doesn't look for proxy settings on the network.", + detail: "Networks this Mac joins can't suggest a proxy through WPAD.", + why: "That's the default, and it means only proxies set on this Mac are used.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -669,17 +912,47 @@ let proxyValueRules: [ValueRule] = proxyProtocols.map { proxy -> ValueRule in ValueRule(.network, .networkLocation, field: "FTPPassive") { context in switch decodeSettingFlag(context.reportedValue) { - case true?: .normal("FTP uses passive mode, the default, which works better through firewalls and routers.") - case false?: .info("FTP uses active mode, which firewalls and routers often block.") - case nil: nil + case true?: + .normal( + "FTP uses passive mode, the default, which works better through firewalls and routers.", + detail: "In passive mode the Mac opens every FTP connection itself.", + why: "Routers and firewalls usually allow connections the Mac opens, so FTP transfers keep working.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "FTP uses active mode, which firewalls and routers often block.", + detail: "In active mode the FTP server opens a connection back to the Mac for each transfer.", + why: "Routers and firewalls often block those incoming connections, so FTP transfers can fail.", + action: "If FTP transfers fail, turn on Use Passive FTP Mode (PASV) in the service's Proxies settings.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.network, .networkLocation, field: "ExcludeSimpleHostnames") { context in switch decodeSettingFlag(context.reportedValue) { - case true?: .info("Simple host names without a domain, such as intranet names, bypass any proxy.", confidence: .documented) - case false?: .info("Simple host names without a domain are treated like other addresses when a proxy is set.", confidence: .documented) - case nil: nil + case true?: + .info( + "Simple host names without a domain, such as intranet names, bypass any proxy.", + detail: "Names like “printer” or “intranet” are reached directly, while full names like “www.example.com” use the proxy.", + why: "Local devices and intranet sites keep working when the proxy can't reach them.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "Simple host names without a domain are treated like other addresses when a proxy is set.", + detail: "Names like “printer” or “intranet” go through the proxy like any other address.", + why: "If a proxy is set and can't reach local names, local devices and intranet sites may not open.", + action: "Nothing to do unless local names fail through the proxy. Then turn on Exclude simple hostnames in the Proxies settings.", + confidence: .documented + ) + case nil: + nil } }, @@ -688,10 +961,19 @@ let proxyValueRules: [ValueRule] = proxyProtocols.map { proxy -> ValueRule in case true?: .info( "VPN On Demand is on: the VPN can connect by itself when its rules match, for example on certain networks.", + detail: "The On Demand rules below it decide when the VPN connects or disconnects.", + why: "Traffic can go through the VPN without anyone starting it, which organizations use to protect work traffic.", + action: "Nothing to do if you or your organization set this up. Review the rules if the VPN connects when you don't expect it.", confidence: .documented ) case false?: - .info("VPN On Demand is off: the VPN connects only when someone or an app starts it.", confidence: .documented) + .info( + "VPN On Demand is off: the VPN connects only when someone or an app starts it.", + detail: "Any On Demand rules for this VPN aren't used.", + why: "Traffic goes through the VPN only while someone has connected it.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -702,25 +984,59 @@ let proxyValueRules: [ValueRule] = proxyProtocols.map { proxy -> ValueRule in return nil } - return switch context.reportedValue { - case "Connect": .info("When this rule matches, the VPN connects automatically.", confidence: .documented) - case "Disconnect": .info("When this rule matches, the VPN disconnects.", confidence: .documented) - case "EvaluateConnection": .info("When this rule matches, the VPN connects only for the domains the rule lists.", confidence: .documented) - case "Ignore": .info("When this rule matches, the VPN is left as it is: running if connected, off if not.", confidence: .documented) - default: .unexplained(context.reportedValue) - } + return onDemandActionExplanation(context.reportedValue) ?? .unexplained(context.reportedValue) }, ValueRule(.networkLocation, field: "InterfaceTypeMatch", unrecognizedValues: .ignore) { context in - switch context.reportedValue { - case "WiFi": .info("This rule applies when the Mac is on Wi-Fi.", confidence: .documented) - case "Ethernet": .info("This rule applies when the Mac is on a wired network.", confidence: .documented) - case "Cellular": .info("This rule applies on a cellular connection.", confidence: .documented) + let connection: String? = switch context.reportedValue { + case "WiFi": "on Wi-Fi" + case "Ethernet": "on a wired network" + case "Cellular": "on a cellular connection" default: nil } + + return connection.map { + .info( + "This rule applies when the Mac is \($0).", + detail: "The rule's action is taken only while the Mac's main connection is \($0).", + why: "It lets the VPN behave differently depending on how the Mac is connected.", + action: "Nothing to do.", + confidence: .documented + ) + } } ] +private func onDemandActionExplanation(_ value: String) -> ValueExplanation? { + let summary: String + let detail: String + + switch value { + case "Connect": + summary = "When this rule matches, the VPN connects automatically." + detail = "Once the rule's conditions are met, macOS starts the VPN without asking." + case "Disconnect": + summary = "When this rule matches, the VPN disconnects." + detail = "Once the rule's conditions are met, macOS stops the VPN and keeps it off." + case "EvaluateConnection": + summary = "When this rule matches, the VPN connects only for the domains the rule lists." + detail = "macOS checks each connection's destination and starts the VPN only for the listed domains." + case "Ignore": + summary = "When this rule matches, the VPN is left as it is: running if connected, off if not." + detail = "macOS neither starts nor stops the VPN because of this rule." + default: + return nil + } + + return .info( + summary, + detail: detail, + why: "On Demand rules decide, without anyone's input, when traffic goes through the VPN.", + action: "Nothing to do if you or your organization set up these rules.", + confidence: .documented + ) +} + // MARK: - Network locations, dial-up and VPN connection settings /// When a PPP or VPN connection ends, keyed by the setting that controls it. @@ -732,24 +1048,151 @@ private let disconnectTriggers: [(field: String, event: String)] = [ ("DisconnectOnWake", "the Mac wakes from sleep") ] -/// Dial-up (PPP) switches, with what each means when it's on and off. -private let dialUpSwitches: [(field: String, whenOn: String, whenOff: String)] = [ - ("DialOnDemand", "The connection dials automatically when an app needs the network.", "The connection dials only when someone connects it."), - ("CommRedialEnabled", "If the line is busy, the connection redials automatically.", "If the line is busy, the connection doesn't redial."), - ("IdleReminder", "macOS asks whether to stay connected after the connection has been idle.", "macOS doesn't ask whether to stay connected when the connection is idle."), - ("LCPEchoEnabled", "The connection regularly checks that the other end still answers, so a dropped line is noticed.", "The connection doesn't check that the other end still answers."), - ("VerboseLogging", "Detailed connection logging is on, which is useful for troubleshooting.", "Detailed connection logging is off."), - ("IPCPCompressionVJ", "TCP header compression is on, which saves bandwidth on slow links.", "TCP header compression is off."), - ("CommDisplayTerminalWindow", "A terminal window opens while dialing, for servers that need manual sign-in.", "No terminal window opens while dialing."), - ("CommUseTerminalScript", "A script runs while dialing to sign in to the server.", "No sign-in script runs while dialing.") +/// A dial-up (PPP) switch: what it means when on and off, what it controls, and why that matters. +private struct DialUpSwitch: Sendable { + let field: String + let whenOn: String + let whenOff: String + let controls: String + let why: String +} + +private let dialUpSwitches: [DialUpSwitch] = [ + DialUpSwitch( + field: "DialOnDemand", + whenOn: "The connection dials automatically when an app needs the network.", + whenOff: "The connection dials only when someone connects it.", + controls: "This setting decides whether apps can start the connection by themselves.", + why: "Automatic dialing can connect without anyone noticing, which matters where calls or connection time cost money." + ), + DialUpSwitch( + field: "CommRedialEnabled", + whenOn: "If the line is busy, the connection redials automatically.", + whenOff: "If the line is busy, the connection doesn't redial.", + controls: "This setting decides what happens when the number is busy.", + why: "Redialing saves trying again by hand, but keeps calling until it gets through." + ), + DialUpSwitch( + field: "IdleReminder", + whenOn: "macOS asks whether to stay connected after the connection has been idle.", + whenOff: "macOS doesn't ask whether to stay connected when the connection is idle.", + controls: "This setting decides whether macOS checks in when nothing has been sent for a while.", + why: "The reminder helps avoid leaving a paid connection open by mistake." + ), + DialUpSwitch( + field: "LCPEchoEnabled", + whenOn: "The connection regularly checks that the other end still answers, so a dropped line is noticed.", + whenOff: "The connection doesn't check that the other end still answers.", + controls: "This setting sends small keep-alive messages (LCP echo) during the connection.", + why: "Without the checks, a dropped connection can look connected until something fails." + ), + DialUpSwitch( + field: "VerboseLogging", + whenOn: "Detailed connection logging is on, which is useful for troubleshooting.", + whenOff: "Detailed connection logging is off.", + controls: "This setting decides how much the connection writes to its log.", + why: "Detailed logs help find connection problems, but grow faster." + ), + DialUpSwitch( + field: "IPCPCompressionVJ", + whenOn: "TCP header compression is on, which saves bandwidth on slow links.", + whenOff: "TCP header compression is off.", + controls: "This setting uses Van Jacobson compression for TCP headers.", + why: "Compression helps on slow dial-up lines. A few servers don't support it." + ), + DialUpSwitch( + field: "CommDisplayTerminalWindow", + whenOn: "A terminal window opens while dialing, for servers that need manual sign-in.", + whenOff: "No terminal window opens while dialing.", + controls: "This setting shows a terminal while the connection is made.", + why: "Only some older dial-up servers need you to type a sign-in by hand." + ), + DialUpSwitch( + field: "CommUseTerminalScript", + whenOn: "A script runs while dialing to sign in to the server.", + whenOff: "No sign-in script runs while dialing.", + controls: "This setting runs a script that answers the server's sign-in prompts.", + why: "Only some older dial-up servers need a script to sign in." + ), + DialUpSwitch( + field: "ACSPEnabled", + whenOn: "The connection accepts routes and search domains sent by the server.", + whenOff: "The connection doesn't accept routes and search domains from the server.", + controls: "This setting turns on Apple's client-server extension to PPP (ACSP), which lets a server send extra network settings.", + why: "Server-sent routes decide which traffic goes through the connection." + ), + DialUpSwitch( + field: "CCPEnabled", + whenOn: "Data compression is negotiated for this connection.", + whenOff: "Data compression isn't negotiated for this connection.", + controls: "This setting uses the PPP Compression Control Protocol (CCP).", + why: "Compression can speed up slow links when both ends support it." + ), + DialUpSwitch( + field: "CCPMPPE40Enabled", + whenOn: "40-bit MPPE encryption is allowed for this connection.", + whenOff: "40-bit MPPE encryption isn't allowed for this connection.", + controls: "MPPE is the encryption old PPTP VPNs used; 40-bit keys are very weak.", + why: "40-bit encryption can be broken easily, so it offers little protection." + ), + DialUpSwitch( + field: "CCPMPPE128Enabled", + whenOn: "128-bit MPPE encryption is allowed for this connection.", + whenOff: "128-bit MPPE encryption isn't allowed for this connection.", + controls: "MPPE is the encryption old PPTP VPNs used.", + why: "MPPE, even with 128-bit keys, is no longer considered secure." + ), + DialUpSwitch( + field: "IPCPUsePeerDNS", + whenOn: "The connection uses the DNS servers the other end provides.", + whenOff: "The connection doesn't use DNS servers from the other end.", + controls: "This setting decides where names are looked up while connected.", + why: "Using the provider's or VPN's DNS servers keeps name lookups working over the connection." + ), + DialUpSwitch( + field: "LCPCompressionACField", + whenOn: "PPP address and control field compression is on.", + whenOff: "PPP address and control field compression is off.", + controls: "This setting drops two fixed header bytes from each PPP frame.", + why: "It saves a little bandwidth on slow links." + ), + DialUpSwitch( + field: "LCPCompressionPField", + whenOn: "PPP protocol field compression is on.", + whenOff: "PPP protocol field compression is off.", + controls: "This setting shortens a header field in each PPP frame.", + why: "It saves a little bandwidth on slow links." + ), + DialUpSwitch( + field: "UseSessionTimer", + whenOn: "The connection ends after a set session time.", + whenOff: "The connection has no session time limit.", + controls: "This setting limits how long one connection can last (Session Timer).", + why: "A time limit avoids long, costly connections, but disconnects even during use." + ) ] private let disconnectRules: [ValueRule] = disconnectTriggers.map { trigger -> ValueRule in ValueRule(.networkLocation, field: trigger.field) { context in switch decodeSettingFlag(context.reportedValue) { - case true?: .info("The connection ends when \(trigger.event).", confidence: .documented) - case false?: .info("The connection stays up when \(trigger.event).", confidence: .documented) - case nil: nil + case true?: + .info( + "The connection ends when \(trigger.event).", + detail: "macOS disconnects this dial-up or VPN connection automatically when \(trigger.event).", + why: "It keeps the connection from staying open when it isn't needed, but it has to be reconnected afterward.", + action: "Nothing to do unless it disconnects when you don't want it to. Then change it in the connection's options.", + confidence: .documented + ) + case false?: + .info( + "The connection stays up when \(trigger.event).", + detail: "macOS leaves this dial-up or VPN connection connected when \(trigger.event).", + why: "The connection stays available without reconnecting, and traffic keeps using it.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil } } } @@ -757,9 +1200,24 @@ private let disconnectRules: [ValueRule] = disconnectTriggers.map { trigger -> V private let dialUpRules: [ValueRule] = dialUpSwitches.map { setting -> ValueRule in ValueRule(.networkLocation, field: setting.field) { context in switch decodeSettingFlag(context.reportedValue) { - case true?: .info(setting.whenOn, confidence: .documented) - case false?: .info(setting.whenOff, confidence: .documented) - case nil: nil + case true?: + .info( + setting.whenOn, + detail: setting.controls, + why: setting.why, + action: "Nothing to do unless you want it off. Change it in the connection's advanced options.", + confidence: .documented + ) + case false?: + .info( + setting.whenOff, + detail: setting.controls, + why: setting.why, + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil } } } @@ -767,43 +1225,121 @@ private let dialUpRules: [ValueRule] = dialUpSwitches.map { setting -> ValueRule let networkLocationSettingValueRules: [ValueRule] = disconnectRules + dialUpRules + [ ValueRule(.networkLocation, field: "spnetworklocation_isActive") { context in switch decodeSettingFlag(context.reportedValue) { - case true?: .info("The location in use when the scan ran.", confidence: .documented) - case false?: .info("A saved location that wasn't in use when the scan ran.", confidence: .documented) - case nil: nil + case true?: + .info( + "The location in use when the scan ran.", + detail: "A network location is a saved set of network settings. This one was active, so its services were the ones in use.", + why: "The settings in this location are the ones that applied to this Mac's connections.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "A saved location that wasn't in use when the scan ran.", + detail: "A network location is a saved set of network settings. This one is stored but wasn't active.", + why: "Its settings don't apply until someone switches to it.", + action: "Nothing to do. You can remove locations you no longer use in System Settings › Network.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.networkLocation, field: "JoinMode") { context in + let summary: String + let detail: String + switch context.reportedValue { - case "Automatic": .normal("Joins known Wi-Fi networks automatically, the default.", confidence: .documented) - case "Preferred": .info("Joins known Wi-Fi networks in the order of the preferred networks list.", confidence: .documented) - case "Ranked": .info("Joins known Wi-Fi networks in a ranked order.", confidence: .documented) - case "Recent": .info("Joins the most recently used known Wi-Fi network.", confidence: .documented) - case "Strongest": .info("Joins the known Wi-Fi network with the strongest signal.", confidence: .documented) - default: nil + case "Automatic": + return .normal( + "Joins known Wi-Fi networks automatically, the default.", + detail: "macOS picks among the Wi-Fi networks this Mac has joined before.", + why: "The Mac reconnects to familiar networks without being asked.", + action: "Nothing to do.", + confidence: .documented + ) + case "Preferred": + summary = "Joins known Wi-Fi networks in the order of the preferred networks list." + detail = "macOS tries known networks in the order they're listed." + case "Ranked": + summary = "Joins known Wi-Fi networks in a ranked order." + detail = "macOS tries known networks by their ranking." + case "Recent": + summary = "Joins the most recently used known Wi-Fi network." + detail = "macOS prefers the known network it used last." + case "Strongest": + summary = "Joins the known Wi-Fi network with the strongest signal." + detail = "macOS prefers whichever known network it hears best." + default: + return nil } + + return .info( + summary, + detail: detail, + why: "It decides which network the Mac joins when several known networks are in range.", + action: "Nothing to do unless the Mac joins the wrong network. Then reorder or remove networks in Wi-Fi settings.", + confidence: .documented + ) }, ValueRule(.networkLocation, field: "AuthenticationMethod") { context in + let summary: String + let detail: String + switch context.reportedValue { - case "Password": .info("The VPN signs in with a password.", confidence: .documented) - case "Certificate": .info("The VPN signs in with a certificate.", confidence: .documented) - case "SharedSecret": .info("The VPN signs in with a shared secret, a password shared by everyone who uses the server.", confidence: .documented) - case "Hybrid": .info("The VPN checks the server's certificate and signs in with a password.", confidence: .documented) - default: nil + case "Password": + summary = "The VPN signs in with a password." + detail = "The VPN account's user name and password are used to connect." + case "Certificate": + summary = "The VPN signs in with a certificate." + detail = "A certificate stored in the keychain identifies this Mac or user to the VPN server." + case "SharedSecret": + summary = "The VPN signs in with a shared secret, a password shared by everyone who uses the server." + detail = "The same secret is given to every user of the VPN server." + case "Hybrid": + summary = "The VPN checks the server's certificate and signs in with a password." + detail = "The server proves who it is with a certificate, and the user signs in with a password." + default: + return nil } + + return .info( + summary, + detail: detail, + why: "Certificates are the strongest of these methods. A shared secret known to many people protects the least.", + action: "Nothing to do. The VPN's administrator decides the sign-in method.", + confidence: .documented + ) }, ValueRule(.network, field: "MediaSubType") { context in mediaSubtypeExplanation(context.reportedValue) }, + ValueRule(.network, field: "MediaOptions") { context in + mediaOptionExplanation(context.reportedValue) + }, + ValueRule(.networkVolumes, field: "spnetworkvolume_automounted") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .info("Mounted automatically, for example by a login item, a saved server, or device management.") + .info( + "Mounted automatically, for example by a login item, a saved server, or device management.", + detail: "The volume was connected without anyone choosing it in the Finder at the time.", + why: "Files on it may be opened or backed up without anyone connecting it by hand.", + action: "Nothing to do if you recognize the server. Check your login items if you don't.", + confidence: .documented + ) case false?: - .info("Not mounted automatically: someone connected to it, for example with Connect to Server in the Finder.") + .info( + "Not mounted automatically: someone connected to it, for example with Connect to Server in the Finder.", + detail: "A person connected this volume by hand.", + why: "It stays connected until it's ejected or the Mac restarts.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -814,9 +1350,20 @@ let networkLocationSettingValueRules: [ValueRule] = disconnectRules + dialUpRule func mediaSubtypeExplanation(_ value: String) -> ValueExplanation? { switch value.lowercased() { case "autoselect": - return .normal("The link speed is negotiated automatically, the default.", confidence: .documented) + return .normal( + "The link speed is negotiated automatically, the default.", + detail: "The Mac and the device at the other end of the cable agree on the fastest speed both support.", + why: "Negotiation gives the best speed without any setup.", + action: "Nothing to do.", + confidence: .documented + ) case "none": - return .info("No link type is set, which is usual for a service with nothing connected or no physical port.") + return .info( + "No link type is set, which is usual for a service with nothing connected or no physical port.", + detail: "The service doesn't report a cable speed, for example because nothing is plugged in or it's a virtual interface.", + why: "It has no effect on connections that are working.", + action: "Nothing to do." + ) default: break } @@ -829,6 +1376,40 @@ func mediaSubtypeExplanation(_ value: String) -> ValueExplanation? { return .info( "The link speed is set by hand to \(ethernetSpeedDescription(megabits: megabits * unit)) instead of being negotiated.", detail: "A fixed speed that doesn't match the other end can make the connection slow or unreliable.", + why: "Both ends of the cable must use the same speed and duplex, or the link drops packets.", + action: "Unless your network needs a fixed speed, set Configure to Automatically in the service's Hardware settings.", confidence: .documented ) } + +/// Reads Ethernet media options: `full-duplex`, `half-duplex`, and `flow-control`. +func mediaOptionExplanation(_ value: String) -> ValueExplanation? { + switch value.lowercased() { + case "full-duplex": + .normal( + "Full duplex: the link sends and receives at the same time.", + detail: "Data can flow both ways on the cable at once.", + why: "Full duplex is the normal mode for modern Ethernet and gives the best speed.", + action: "Nothing to do.", + confidence: .documented + ) + case "half-duplex": + .info( + "Half duplex: the link sends or receives, but not both at once.", + detail: "Data flows one way at a time, as on old hubs.", + why: "Half duplex is slower, and if the other end uses full duplex the link drops packets.", + action: "Unless your network needs it, set the service's Ethernet configuration to Automatically.", + confidence: .documented + ) + case "flow-control": + .info( + "Flow control is on: either end can ask the other to pause briefly.", + detail: "When one side can't keep up, it can signal the other to wait.", + why: "It avoids lost packets when one side is busier, at the cost of short pauses.", + action: "Nothing to do.", + confidence: .documented + ) + default: + nil + } +} diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index df61a24..a35f245 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -49,6 +49,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr /// Every value the app explains for fields with a limited set of values. let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples + + networkValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -121,6 +122,80 @@ private let extensionValueSamples: [ValueSample] = { return samples }() +private let networkValueSamples: [ValueSample] = { + let serviceTypes: [String] = [ + "Ethernet", "AirPort", "IEEE80211", "Bridge", "Bond", "VLAN", "6to4", "IPSec", "PPP", "PPP (PPPSerial)", + "PPP (PPPoE)", "PPP (L2TP)", "PPP (PPTP)", "VPN", "VPN (com.example.vpn)" + ] + let ipv4Methods: [String] = ["DHCP", "Manual", "INFORM", "BOOTP", "LinkLocal", "Automatic", "PPP", "VPN"] + let ipv6Methods: [String] = ["Automatic", "LinkLocal", "Manual", "RouterAdvertisement", "6to4"] + let proxySwitches: [String] = [ + "HTTPEnable", "HTTPSEnable", "SOCKSEnable", "FTPEnable", "GopherEnable", "RTSPEnable", + "ProxyAutoConfigEnable", "ProxyAutoDiscoveryEnable", "FTPPassive", "ExcludeSimpleHostnames" + ] + let connectionSwitches: [String] = [ + "DisconnectOnIdle", "DisconnectOnLogout", "DisconnectOnSleep", "DisconnectOnFastUserSwitch", "DisconnectOnWake", + "DialOnDemand", "CommRedialEnabled", "IdleReminder", "LCPEchoEnabled", "VerboseLogging", "IPCPCompressionVJ", + "CommDisplayTerminalWindow", "CommUseTerminalScript", "ACSPEnabled", "CCPEnabled", "CCPMPPE40Enabled", + "CCPMPPE128Enabled", "IPCPUsePeerDNS", "LCPCompressionACField", "LCPCompressionPField", "UseSessionTimer" + ] + let service: [String] = ["spnetworklocation_services", "[]"] + var samples: [ValueSample] = [] + + for dataType in [SystemProfilerDataType.network, .networkLocation] { + samples += ["Ethernet", "AirPort", "FireWire", "Modem"].map { ValueSample(dataType, ["hardware"], $0) } + samples += serviceTypes.map { ValueSample(dataType, ["type"], $0) } + samples += ipv4Methods.map { ValueSample(dataType, ["IPv4", "ConfigMethod"], $0) } + samples += ipv6Methods.map { ValueSample(dataType, ["IPv6", "ConfigMethod"], $0) } + + for proxySwitch in proxySwitches { + for value in ["yes", "no", "1", "0"] { + samples.append(ValueSample(dataType, ["Proxies", proxySwitch], value)) + } + } + } + + samples.append(ValueSample( + .network, + ["hardware"], + "Modem", + siblings: ["_name": .string("nRF52 USB Product"), "type": .string("PPP (PPPSerial)"), "interface": .string("usbmodem0001")] + )) + + for value in ["true", "false"] { + samples.append(ValueSample(.networkLocation, service + ["VPN", "OnDemandEnabled"], value)) + } + + samples += ["Connect", "Disconnect", "EvaluateConnection", "Ignore"].map { + ValueSample(.networkLocation, service + ["VPN", "OnDemandRules", "[]", "Action"], $0) + } + samples += ["WiFi", "Ethernet", "Cellular"].map { + ValueSample(.networkLocation, service + ["VPN", "OnDemandRules", "[]", "InterfaceTypeMatch"], $0) + } + + for connectionSwitch in connectionSwitches { + for value in ["yes", "no"] { + samples.append(ValueSample(.networkLocation, service + ["PPP", connectionSwitch], value)) + } + } + + samples += ["yes", "no"].map { ValueSample(.networkLocation, ["spnetworklocation_isActive"], $0) } + samples += ["Automatic", "Preferred", "Ranked", "Recent", "Strongest"].map { + ValueSample(.networkLocation, service + ["IEEE80211", "JoinMode"], $0) + } + samples += ["Password", "Certificate", "SharedSecret", "Hybrid"].map { + ValueSample(.networkLocation, service + ["VPN", "AuthenticationMethod"], $0) + } + samples += ["autoselect", "none", "10baseT/UTP", "100baseTX", "1000baseT", "10GbaseT"].map { + ValueSample(.network, ["Ethernet", "MediaSubType"], $0) + } + samples += ["full-duplex", "half-duplex", "flow-control"].map { + ValueSample(.network, ["Ethernet", "MediaOptions", "[]"], $0) + } + samples += ["yes", "no"].map { ValueSample(.networkVolumes, ["spnetworkvolume_automounted"], $0) } + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { @@ -211,6 +286,30 @@ struct ValueCatalogTests { #expect(onAppleSilicon.detail?.contains("only for Macs with Apple silicon") == true) } + // MARK: - Network + + @Test + func pppSubtypesAreToldApart() throws { + func summary(_ value: String) -> String? { + valueExplanation(dataType: .network, path: ["type"], scalar: .string(value))?.summary + } + + #expect(summary("PPP (PPPSerial)")?.contains("serial port") == true) + #expect(summary("PPP (PPPoE)")?.contains("PPPoE") == true) + #expect(summary("PPP (L2TP)")?.contains("L2TP") == true) + #expect(summary("PPP (PPTP)")?.contains("no longer supports") == true) + #expect(summary("PPP")?.contains("serial port") == false) + #expect(valueExplanation(dataType: .network, path: ["type"], scalar: .string("PPP (FutureLink)"))?.status == .unknown) + } + + @Test + func aProxyThatsOnSaysWhyItMatters() throws { + let on = try #require(valueExplanation(dataType: .network, path: ["Proxies", "HTTPSEnable"], scalar: .string("yes"))) + + #expect(on.significance?.contains("see, log, and filter") == true) + #expect(on.suggestedAction?.contains("System Settings") == true) + } + @Test func processorFamilyComesFromTheHardwareOverview() { let appleSilicon: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "chip_type": .string("Apple M1")])] diff --git a/docs/value-explanations.md b/docs/value-explanations.md index d4c7727..cc17d0c 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -117,3 +117,51 @@ Apple keys that current macOS may no longer report. | `spext_runtime_environment` | `spext_arch_arm`, `spext_arch_x86`, `spext_universal`, `spext_arch_ppc` | kind | Info, Normal (Universal) | Apple | Apple key | | `spext_obtained_from` | `spext_apple`, `spext_identified_developer`, `spext_unknown`, `spext_not_signed` | where it came from | as for apps; Info for not signed | Apple | Apple key | | `spext_notarized` | `spext_yes`, `spext_no` | notarized or not | Normal, Info | Apple | Apple key | + +## Network services and locations + +Sources: service hardware, service types and PPP subtypes, IPv4 and IPv6 +configuration methods, proxy switches, PPP options, AirPort join modes, and VPN +authentication methods are System Configuration constants, documented in +Apple's `SCSchemaDefinitions.h` and `SCNetworkConfiguration.h` +(). VPN On +Demand rule actions and interface types are documented in Apple's device +management reference for `VPN.OnDemandRulesElement` +(). +Apple removed its PPTP client in macOS Sierra +(). Ethernet media options are keys in +Apple's `SPNetworkReporter` strings. + +| field | values | status | source | spelling | +|---|---|---|---|---| +| `hardware` | `Ethernet`, `AirPort`, `Modem` | Normal; Modem Info (a USB serial device is Inferred) | Apple, Inferred | seen | +| `hardware` | `FireWire` | Info | Apple | Apple key | +| `type` | `Ethernet`, `AirPort`, `IEEE80211`, `Bridge`, `PPP`, `PPP (PPPSerial)`, `VPN`, `VPN ()` | Normal (Ethernet, Wi-Fi); Info | Apple | seen | +| `type` | `Bond`, `VLAN`, `6to4`, `IPSec` | Info | Apple | Apple constant | +| `type` | `PPP (PPPoE)`, `PPP (L2TP)`, `PPP (PPTP)` | Info | Apple | subtypes are Apple keys; the `PPP (subtype)` form is unconfirmed for these | +| IPv4 `ConfigMethod` | `DHCP`, `Manual`, `PPP`, `VPN` | Normal (DHCP); Info | Apple | seen | +| IPv4 `ConfigMethod` | `INFORM`, `BOOTP`, `LinkLocal`, `Automatic` | Info; Normal (Automatic) | Apple | Apple key | +| IPv6 `ConfigMethod` | `Automatic` | Normal | Apple | seen | +| IPv6 `ConfigMethod` | `LinkLocal`, `Manual`, `RouterAdvertisement`, `6to4` | Info; Normal (RouterAdvertisement) | Apple | Apple constant | +| proxy switches (`HTTPEnable`, `HTTPSEnable`, `SOCKSEnable`, `FTPEnable`, `GopherEnable`, `RTSPEnable`) | on, off (`yes`/`no`, `1`/`0`) | Info when on, Normal when off | Apple | seen | +| `ProxyAutoConfigEnable`, `ProxyAutoDiscoveryEnable` | on, off | Info when on, Normal when off | Apple | seen | +| `FTPPassive` | on, off | Normal, Info | Apple | seen | +| `ExcludeSimpleHostnames` | on, off | Info | Apple | seen (withheld values) | +| `OnDemandEnabled` | `true`, `false` | Info | Apple | seen | +| On Demand `Action` | `Connect`, `Disconnect`, `EvaluateConnection`, `Ignore` | Info | Apple | seen (`Connect`), Apple docs | +| `InterfaceTypeMatch` | `WiFi`, `Ethernet`, `Cellular` | Info | Apple | Apple docs (the inventory withheld them) | +| `DisconnectOnIdle`, `DisconnectOnLogout`, `DisconnectOnSleep`, `DisconnectOnFastUserSwitch`, `DisconnectOnWake` | on, off | Info | Apple | seen | +| PPP switches (`DialOnDemand`, `CommRedialEnabled`, `IdleReminder`, `LCPEchoEnabled`, `VerboseLogging`, `IPCPCompressionVJ`, `CommDisplayTerminalWindow`, `CommUseTerminalScript`, `ACSPEnabled`) | on, off | Info | Apple | seen | +| PPP switches (`CCPEnabled`, `CCPMPPE40Enabled`, `CCPMPPE128Enabled`, `IPCPUsePeerDNS`, `LCPCompressionACField`, `LCPCompressionPField`, `UseSessionTimer`) | on, off | Info | Apple | Apple key | +| `spnetworklocation_isActive` | on, off | Info | Apple | seen (withheld values) | +| `JoinMode` | `Automatic` | Normal | Apple | seen | +| `JoinMode` | `Preferred`, `Ranked`, `Recent`, `Strongest` | Info | Apple | Apple constant | +| `AuthenticationMethod` | `Password` | Info | Apple | seen | +| `AuthenticationMethod` | `Certificate`, `SharedSecret`, `Hybrid` | Info | Apple | Apple constant | +| `MediaSubType` | `autoselect`, `none` | Normal, Info | Apple | seen | +| `MediaSubType` | fixed speeds such as `1000baseT`, `100baseTX`, `10GbaseT` | Info | Apple | Apple key (`100baseTX`, `1000baseT`); other speeds unconfirmed | +| `MediaOptions` | `full-duplex`, `half-duplex`, `flow-control` | Normal, Info | Apple | Apple key | +| `spnetworkvolume_automounted` | on, off | Info | Apple | seen (withheld values) | + +`ACSPEnabled` is explained from its Apple name ("ACSP Enabled") and Apple's +PPP documentation; what the server sends is not visible in the report. From 8719da03f7d2f293d9a89db7247bed4e3df6e192 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:53:00 +0000 Subject: [PATCH 07/15] Explain install sources, Rosetta reasons, and firewall states Install history sources, legacy software reasons, and every firewall value now have every part of an explanation. The Rosetta 2 plan moves from what the value means to why it matters, and its test follows it. spfirewall_globalstate_allow_all, the key Apple's firewall strings use for allowing all incoming connections, now reads as the firewall being off, both in the row and in the at-a-glance summary. spfirewall_allow_local is recognized for per-app rules. --- PLAN.md | 2 +- .../SettingsAndSoftwareValueRules.swift | 11 +++- .../Values/SoftwareArtifactValueRules.swift | 15 ++++- .../Values/SystemValueRules.swift | 60 +++++++++++++++++-- .../Core/Presentation/ReportGlance.swift | 2 +- .../SecondWaveValueTests.swift | 2 +- .../ValueCatalogTests.swift | 37 +++++++++++- docs/value-explanations.md | 23 +++++++ 8 files changed, 138 insertions(+), 14 deletions(-) diff --git a/PLAN.md b/PLAN.md index 7b5bd1b..9b40e0d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -83,7 +83,7 @@ test run. configuration, proxies, VPN On Demand and its rules, PPP and VPN switches, Wi-Fi join mode, VPN sign-in, Ethernet media, active location, network volumes. -7. [ ] Install history, legacy software, and firewall. +7. [x] Install history, legacy software, and firewall. 8. [ ] Wi-Fi: status, security, network type, capabilities, regulatory locale. 9. [ ] Power and battery: battery condition and charge states, power diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift index 98cbccd..1bb958f 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift @@ -2,22 +2,27 @@ import Foundation // MARK: - Legacy software +// Sources: reason_x86_only and reason_x86_forced_environmental appear in +// docs/value-inventory.md. Apple's Rosetta plans are in Apple Developer News +// (https://developer.apple.com/news/?id=w5ngl9k2). let legacySoftwareValueRules: [ValueRule] = [ ValueRule(.legacySoftware, field: "reason") { context in - let rosettaNote: String = "Apple has said Rosetta 2 remains fully available through macOS 27, and after that only for some older games, so Intel-only apps may stop working in later releases." + let rosettaNote: String = "Translated apps use more power and can be slower. Apple has said Rosetta 2 remains fully available through macOS 27, and after that only for some older games, so Intel-only apps may stop working in later releases." switch tokenSuffix(context.reportedValue, after: "reason_") { case "x86_only": return .info( "Built for Intel Macs only, so it runs through Rosetta 2 translation.", - detail: rosettaNote, + detail: "The app contains Intel code only, so on this Mac Rosetta 2 translates it to run on Apple silicon.", + why: rosettaNote, action: "Check whether the developer offers a version for Apple silicon.", confidence: .documented ) case "x86_forced_environmental": return .info( "Set to run as Intel code through Rosetta 2, even if it may also support Apple silicon.", - detail: "This happens when “Open using Rosetta” is selected for the app, or when it's started from a process that runs under Rosetta. \(rosettaNote)", + detail: "This happens when “Open using Rosetta” is selected for the app, or when it's started from a process that runs under Rosetta.", + why: rosettaNote, action: "If the app supports Apple silicon, turn off “Open using Rosetta” in its Get Info window.", confidence: .likely(reasons: [ "The value names an Intel (x86) requirement that comes from the environment rather than the app itself." diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift index e1e6bd0..5cee3d2 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift @@ -15,12 +15,23 @@ let softwareArtifactValueRules: [ValueRule] = [ originExplanation(context.reportedValue) }, + // package_source_apple and package_source_other appear in docs/value-inventory.md. ValueRule(.installHistory, field: "package_source") { context in switch tokenSuffix(context.reportedValue, after: "package_source_") { case "apple": - .normal("Installed by Apple, such as a macOS or security update.") + .normal( + "Installed by Apple, such as a macOS or security update.", + detail: "The package came from Apple: a macOS update, a security response, or an Apple app or component.", + why: "Apple updates keep macOS secure, and this history shows when each one was installed.", + action: "Nothing to do." + ) case "other": - .info("Installed from a third-party installer package.") + .info( + "Installed from a third-party installer package.", + detail: "The package came from a developer other than Apple, installed with Installer or a management tool.", + why: "Installer packages can place software anywhere on the Mac, including background items that start at login.", + action: "Nothing to do if you recognize the software. If you don't, check System Settings › General › Login Items & Extensions for items it added." + ) default: nil } diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift index d502814..5f1f238 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift @@ -202,23 +202,37 @@ func uptimeExplanation(_ value: String) -> ValueExplanation? { // MARK: - Firewall +// Sources: global states and per-app states are keys in Apple's SPFirewallReporter +// strings (spfirewall_globalstate_limit_connections, _block_all, _allow_all; +// spfirewall_allow_all, spfirewall_block_all, spfirewall_allow_local). Apple's +// firewall settings are described in https://support.apple.com/guide/mac-help/mh34041. +// spfirewall_globalstate_off is unconfirmed; older macOS reported the firewall being +// off as allow_all ("Allow all incoming connections"). + let firewallValueRules: [ValueRule] = [ ValueRule(.firewall, field: "spfirewall_globalstate") { context in switch tokenSuffix(context.reportedValue, after: "globalstate_") { case "limit_connections": .normal( "The firewall is on and lets only allowed apps and services accept incoming connections.", + detail: "Incoming connections are blocked unless the app or service receiving them is allowed, by you or automatically because it's signed.", + why: "Other devices on the network can't reach services on this Mac unless they're allowed.", + action: "Nothing to do. Review the allowed apps in System Settings › Network › Firewall › Options.", confidence: .documented ) case "block_all": .normal( "The firewall is on and blocks all incoming connections except those basic internet services need.", + detail: "Only basic services, such as getting a network address, can receive incoming connections. Sharing services can't.", + why: "It's the strictest setting. It also stops features like screen sharing, file sharing, and AirPlay to this Mac.", + action: "Nothing to do, unless a sharing feature you use stops working.", confidence: .documented ) - case "off": + case "off", "allow_all": .review( "The firewall is off, which is the macOS default.", detail: "Other devices on the same network can reach services this Mac offers, such as file or screen sharing.", + why: "Any sharing service that's turned on can be reached by every device on the same network, including public Wi-Fi.", action: "Turn it on in System Settings › Network › Firewall, especially if you use public Wi-Fi.", confidence: .documented ) @@ -230,9 +244,29 @@ let firewallValueRules: [ValueRule] = [ ValueRule(.firewall, field: "spfirewall_applications") { context in switch tokenSuffix(context.reportedValue, after: "spfirewall_") { case "allow_all": - .info("This app may accept incoming connections from other devices.", confidence: .documented) + .info( + "This app may accept incoming connections from other devices.", + detail: "When the firewall is on, it lets other devices connect to this app.", + why: "An app that accepts connections can be reached from the network, so it should be one you trust.", + action: "If you don't recognize the app, set it to block incoming connections in Firewall Options.", + confidence: .documented + ) case "block_all": - .normal("Incoming connections to this app are blocked.", confidence: .documented) + .normal( + "Incoming connections to this app are blocked.", + detail: "The firewall stops other devices from connecting to this app.", + why: "The app can still connect out, but features that need incoming connections, such as sharing, won't work.", + action: "Nothing to do unless one of this app's features needs incoming connections.", + confidence: .documented + ) + case "allow_local": + .info( + "Only devices on the local network may connect to this app.", + detail: "Connections from the same local network are allowed, and others are blocked.", + why: "Devices on your network can reach it, but devices elsewhere on the internet can't.", + action: "Nothing to do if you trust the networks you use.", + confidence: .documented + ) default: nil } @@ -243,11 +277,17 @@ let firewallValueRules: [ValueRule] = [ case true?: .normal( "Stealth mode is on: this Mac doesn't answer probing requests such as ping.", + detail: "The Mac ignores ping and doesn't reply to connection attempts on closed ports.", + why: "It makes the Mac harder to find with network scans, especially on public networks.", + action: "Nothing to do.", confidence: .documented ) case false?: .info( "Stealth mode is off: this Mac answers some probing requests, such as ping. That's the default.", + detail: "The Mac answers ping and reports closed ports, as most computers do.", + why: "Other devices on the network can discover the Mac more easily.", + action: "On public networks, you can turn on stealth mode in System Settings › Network › Firewall › Options.", confidence: .documented ) case nil: @@ -258,9 +298,19 @@ let firewallValueRules: [ValueRule] = [ ValueRule(.firewall, field: "spfirewall_loggingenabled") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .info("Firewall logging is on, so blocked connections are recorded in the system log.") + .info( + "Firewall logging is on, so blocked connections are recorded in the system log.", + detail: "Each connection the firewall blocks is written to the log.", + why: "The log helps find out why a connection to this Mac didn't work.", + action: "Nothing to do." + ) case false?: - .info("Firewall logging is off.") + .info( + "Firewall logging is off.", + detail: "Connections the firewall blocks aren't recorded.", + why: "There's no record to check if a connection is blocked unexpectedly.", + action: "Nothing to do." + ) case nil: nil } diff --git a/SystemProfilerExplorer/Core/Presentation/ReportGlance.swift b/SystemProfilerExplorer/Core/Presentation/ReportGlance.swift index d9aa19a..5c5bdf8 100644 --- a/SystemProfilerExplorer/Core/Presentation/ReportGlance.swift +++ b/SystemProfilerExplorer/Core/Presentation/ReportGlance.swift @@ -227,7 +227,7 @@ private func firewallGlance(_ report: SystemProfilerReport) -> String? { switch state { case "limit_connections", "block_all": return "The firewall is on\(stealth == true ? ", with stealth mode on" : "")." - case "off": + case "off", "allow_all": return "The firewall is off." default: return nil diff --git a/SystemProfilerExplorerTests/SecondWaveValueTests.swift b/SystemProfilerExplorerTests/SecondWaveValueTests.swift index cdb5b33..71f1fca 100644 --- a/SystemProfilerExplorerTests/SecondWaveValueTests.swift +++ b/SystemProfilerExplorerTests/SecondWaveValueTests.swift @@ -55,7 +55,7 @@ struct SecondWaveValueTests { let intelOnly = try #require(valueExplanation(dataType: .legacySoftware, path: ["reason"], scalar: .string("reason_x86_only"))) let forced = try #require(valueExplanation(dataType: .legacySoftware, path: ["reason"], scalar: .string("reason_x86_forced_environmental"))) - #expect(intelOnly.detail?.contains("macOS 27") == true) + #expect(intelOnly.significance?.contains("macOS 27") == true) #expect(intelOnly.confidence == .documented) #expect(forced.confidence?.reasons.isEmpty == false) } diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index a35f245..62a14d2 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -49,7 +49,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr /// Every value the app explains for fields with a limited set of values. let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples - + networkValueSamples + + networkValueSamples + softwareHistoryAndFirewallValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -196,6 +196,27 @@ private let networkValueSamples: [ValueSample] = { return samples }() +private let softwareHistoryAndFirewallValueSamples: [ValueSample] = { + var samples: [ValueSample] = ["package_source_apple", "package_source_other"].map { + ValueSample(.installHistory, ["package_source"], $0) + } + + samples += ["reason_x86_only", "reason_x86_forced_environmental"].map { ValueSample(.legacySoftware, ["reason"], $0) } + samples += [ + "spfirewall_globalstate_limit_connections", "spfirewall_globalstate_block_all", + "spfirewall_globalstate_allow_all", "spfirewall_globalstate_off" + ].map { ValueSample(.firewall, ["spfirewall_globalstate"], $0) } + samples += ["spfirewall_allow_all", "spfirewall_block_all", "spfirewall_allow_local"].map { + ValueSample(.firewall, ["spfirewall_applications", "com.example.app"], $0) + } + + for field in ["spfirewall_stealthenabled", "spfirewall_loggingenabled"] { + samples += ["Yes", "No"].map { ValueSample(.firewall, [field], $0) } + } + + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { @@ -310,6 +331,20 @@ struct ValueCatalogTests { #expect(on.suggestedAction?.contains("System Settings") == true) } + // MARK: - Firewall + + @Test + func olderFirewallOffSpellingReadsAsOff() throws { + let allowAll = try #require(valueExplanation( + dataType: .firewall, + path: ["spfirewall_globalstate"], + scalar: .string("spfirewall_globalstate_allow_all") + )) + + #expect(allowAll.status == .worthReviewing) + #expect(allowAll.summary.contains("off")) + } + @Test func processorFamilyComesFromTheHardwareOverview() { let appleSilicon: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "chip_type": .string("Apple M1")])] diff --git a/docs/value-explanations.md b/docs/value-explanations.md index cc17d0c..6fba260 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -165,3 +165,26 @@ Apple's `SPNetworkReporter` strings. `ACSPEnabled` is explained from its Apple name ("ACSP Enabled") and Apple's PPP documentation; what the server sends is not visible in the report. + +## Install history, legacy software, and firewall + +Sources: `package_source_*` and `reason_*` values appear in +`docs/value-inventory.md`. Firewall states are keys in Apple's +`SPFirewallReporter` strings, and the firewall settings are described in the +Mac User Guide (). + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `package_source` | `package_source_apple` | Normal | Standard | seen | +| `package_source` | `package_source_other` | Info | Standard | seen | +| `reason` (legacy software) | `reason_x86_only` | Info | Apple | seen | +| `reason` (legacy software) | `reason_x86_forced_environmental` | Info | Inferred | seen | +| `spfirewall_globalstate` | `spfirewall_globalstate_limit_connections` | Normal | Apple | seen | +| `spfirewall_globalstate` | `spfirewall_globalstate_block_all` | Normal | Apple | Apple key | +| `spfirewall_globalstate` | `spfirewall_globalstate_allow_all` (firewall off) | Worth a look | Apple | Apple key | +| `spfirewall_globalstate` | `spfirewall_globalstate_off` | Worth a look | Apple | unconfirmed | +| `spfirewall_applications` | `spfirewall_allow_all` | Info | Apple | Apple key (values withheld in the inventory) | +| `spfirewall_applications` | `spfirewall_block_all` | Normal | Apple | Apple key | +| `spfirewall_applications` | `spfirewall_allow_local` | Info | Apple | Apple key | +| `spfirewall_stealthenabled` | `Yes`, `No` | Normal, Info | Apple | seen (`Yes`) | +| `spfirewall_loggingenabled` | `Yes`, `No` | Info | Standard | seen (`No`) | From d6fa1b7bbcd8a3f269a920a82db9d802aa1afd12 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:58:24 +0000 Subject: [PATCH 08/15] Explain every Wi-Fi status, security, network type, and capability value Adds the Apple spellings for Not Associated, Network Service Inactive, WEP 40/128, 802.1X, WPS, the WPA/WPA2 mixed modes, Internet Sharing networks, unsupported capabilities, and AirPlay screen mirroring. Every Wi-Fi value now says what it means, why it matters, and what to check. The locale rule moves next to the other Wi-Fi rules. --- PLAN.md | 2 +- .../Values/DisplayAndMediaValueRules.swift | 10 - .../Values/NetworkValueRules.swift | 337 +++++++++++++++--- .../ValueCatalogTests.swift | 73 +++- docs/value-explanations.md | 44 +++ 5 files changed, 410 insertions(+), 56 deletions(-) diff --git a/PLAN.md b/PLAN.md index 9b40e0d..cefc802 100644 --- a/PLAN.md +++ b/PLAN.md @@ -84,7 +84,7 @@ test run. switches, Wi-Fi join mode, VPN sign-in, Ethernet media, active location, network volumes. 7. [x] Install history, legacy software, and firewall. -8. [ ] Wi-Fi: status, security, network type, capabilities, regulatory +8. [x] Wi-Fi: status, security, network type, capabilities, regulatory locale. 9. [ ] Power and battery: battery condition and charge states, power source, hibernate mode, Low and High Power Mode, network reachability, diff --git a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift index d03f2d9..43be4be 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift @@ -241,16 +241,6 @@ let hardwareStateValueRules: [ValueRule] = [ } }, - ValueRule(.wifi, field: "spairport_wireless_locale") { context in - switch context.reportedValue.uppercased() { - case "FCC": .info("Wi-Fi follows the United States (FCC) rules for channels and transmit power.", confidence: .documented) - case "ETSI": .info("Wi-Fi follows the European (ETSI) rules for channels and transmit power.", confidence: .documented) - case "MKK", "JAPAN": .info("Wi-Fi follows the Japanese (MKK) rules for channels and transmit power.", confidence: .documented) - case "ROW": .info("Wi-Fi follows a general set of rules for channels and transmit power used outside specific regions.") - default: nil - } - }, - ValueRule(.usb, field: "USBKeyHardwareType") { context in switch context.reportedValue { case "Built-in": .info("A USB controller built into this Mac.") diff --git a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift index 6ec2630..46ca882 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift @@ -499,14 +499,21 @@ func usbLinkExplanation(_ value: String, adapterMegabits: Int?) -> ValueExplanat // MARK: - Wi-Fi +// Sources: the values seen in docs/value-inventory.md (spairport_status_connected, +// the four security modes, spairport_network_type_station, spairport_caps_supported, +// FCC, US) and the keys in Apple's SPAirPortReporter strings: spairport_status_ +// connected, _off, _disassociated and _inactive; spairport_security_mode_none, _wep, +// _wep40, _wep128, _8021x, _wps, _wpa_personal, _wpa_enterprise, _wpa2_personal, +// _wpa2_personal_mixed, _wpa2_enterprise, _wpa2_enterprise_mixed and _wpa3_personal; +// spairport_network_type_station, _ibss and _sharing; spairport_caps_supported and +// _unsupported. Published output shows the locales ETSI and RoW. Unconfirmed +// spellings, kept because older versions of this app matched them: status +// disconnected and not_associated; security modes wpa_personal_mixed, wpa3_enterprise, +// wpa2_wpa3_enterprise and owe; locale MKK. Signal bands are common Wi-Fi guidance. + let wifiValueRules: [ValueRule] = [ ValueRule(.wifi, field: "spairport_status_information") { context in - switch tokenSuffix(context.reportedValue, after: "status_") { - case "connected": .normal("Connected to a Wi-Fi network.") - case "off": .info("Wi-Fi is turned off.") - case "disconnected", "inactive", "not_associated": .info("Wi-Fi is on but not connected to a network.") - default: nil - } + wifiStatusExplanation(context.reportedValue) }, ValueRule(.wifi, field: "spairport_security_mode") { context in @@ -530,30 +537,47 @@ let wifiValueRules: [ValueRule] = [ ValueRule(.wifi, field: "spairport_network_phymode", unrecognizedValues: .ignore) { context in wifiGeneration(context.reportedValue).map { generation in context.pathContains("spairport_current_network_information") - ? .normal("Connected using \(generation).", confidence: .documented) - : .info("This network supports up to \(generation).", confidence: .documented) + ? .normal( + "Connected using \(generation).", + detail: "This is the Wi-Fi standard this Mac and the router agreed on for the current connection.", + why: "Newer standards are faster and cope better with busy networks. The connection uses the newest standard both sides support.", + action: "Nothing to do. If it's older than your router supports, check the router's settings or move closer to it.", + confidence: .documented + ) + : .info( + "This network supports up to \(generation).", + detail: "This is the newest Wi-Fi standard the nearby network advertised when the scan ran.", + why: "It's a network this Mac could see, not necessarily one it uses.", + action: "Nothing to do.", + confidence: .documented + ) } }, ValueRule(.wifi, field: "spairport_supported_phymodes", unrecognizedValues: .ignore) { context in wifiGeneration(context.reportedValue).map { - .info("This Mac's Wi-Fi supports up to \($0).", confidence: .documented) + .info( + "This Mac's Wi-Fi supports up to \($0).", + detail: "This is the list of Wi-Fi standards the Mac's Wi-Fi hardware can use. The newest one is named here.", + why: "A connection can't be faster than the older of this and the router's newest standard.", + action: "Nothing to do.", + confidence: .documented + ) } }, ValueRule(.wifi, field: "spairport_network_type") { context in - switch tokenSuffix(context.reportedValue, after: "network_type_") { - case "station": .info("A regular network hosted by a router or access point.") - case "ibss": .info("A direct computer-to-computer (ad hoc) network.") - default: nil - } + wifiNetworkTypeExplanation(context.reportedValue) }, ValueRule(.wifi, field: "spairport_network_rate", unrecognizedValues: .ignore) { context in leadingInteger(context.reportedValue).map { rate in .info( "The link between this Mac and the router runs at up to \(rate.formatted()) Mbps.", - detail: "Internet speed is usually lower, because it depends on the internet connection itself." + detail: "Internet speed is usually lower, because it depends on the internet connection itself.", + why: "This rate changes all the time with signal strength and interference. It's the ceiling for traffic inside your network, such as backups and file sharing.", + action: "Nothing to do. If it's much lower than usual, check the signal and move closer to the router.", + confidence: .documented ) } }, @@ -566,70 +590,225 @@ let wifiValueRules: [ValueRule] = [ wifiRegionExplanation(context.reportedValue) }, + ValueRule(.wifi, field: "spairport_wireless_locale") { context in + wifiLocaleExplanation(context.reportedValue) + }, + ValueRule(.wifi, field: "spairport_caps_airdrop") { context in - wifiCapabilityExplanation(context.reportedValue, feature: "AirDrop") + wifiCapabilityExplanation( + context.reportedValue, + feature: "AirDrop", + why: "AirDrop sends files directly to nearby Apple devices over Wi-Fi." + ) }, ValueRule(.wifi, field: "spairport_caps_autounlock") { context in - wifiCapabilityExplanation(context.reportedValue, feature: "unlocking with Apple Watch") + wifiCapabilityExplanation( + context.reportedValue, + feature: "unlocking with Apple Watch", + why: "Auto Unlock uses Wi-Fi to measure how close your Apple Watch is before it unlocks the Mac." + ) }, ValueRule(.wifi, field: "spairport_caps_wow") { context in - wifiCapabilityExplanation(context.reportedValue, feature: "waking over Wi-Fi (Wake on Wireless)") + wifiCapabilityExplanation( + context.reportedValue, + feature: "waking over Wi-Fi (Wake on Wireless)", + why: "It lets other devices wake the Mac over Wi-Fi to reach shared files, printers, or screen sharing." + ) + }, + + ValueRule(.wifi, field: "spairport_caps_awdl") { context in + wifiCapabilityExplanation( + context.reportedValue, + feature: "Apple Wireless Direct Link (used by AirPlay screen mirroring, AirDrop, and Sidecar)", + why: "These features connect directly to nearby Apple devices, without going through a router." + ) } ] +private func wifiStatusExplanation(_ value: String) -> ValueExplanation? { + switch tokenSuffix(value, after: "status_") { + case "connected": + .normal( + "Connected to a Wi-Fi network.", + detail: "Wi-Fi was on and joined to a network when the scan ran.", + why: "The Mac can reach the network and, through it, the internet.", + action: "Nothing to do.", + confidence: .documented + ) + case "off": + .info( + "Wi-Fi is turned off.", + detail: "The Wi-Fi radio was off when the scan ran, so it can't see or join networks.", + why: "The Mac needs another connection, such as Ethernet, to reach the network. AirDrop and some Continuity features also need Wi-Fi.", + action: "If you expected Wi-Fi to be on, turn it on in Control Center or System Settings › Wi-Fi.", + confidence: .documented + ) + case "disassociated", "disconnected", "not_associated": + .info( + "Wi-Fi is on but not connected to a network.", + detail: "The Wi-Fi radio was on, but it wasn't joined to any network when the scan ran.", + why: "Without a network, the Mac can't use Wi-Fi to reach the internet.", + action: "If you expected a connection, choose a network in System Settings › Wi-Fi. If it keeps dropping, check the password and the router.", + confidence: .documented + ) + case "inactive": + .info( + "The Wi-Fi network service is inactive.", + detail: "Wi-Fi hardware is present, but its service is turned off or deactivated in Network settings.", + why: "macOS won't use Wi-Fi for network traffic while its service is inactive.", + action: "If you want to use Wi-Fi, check that the Wi-Fi service is active in System Settings › Network.", + confidence: .documented + ) + default: + nil + } +} + func wifiSecurityExplanation(_ value: String, isCurrentNetwork: Bool) -> ValueExplanation? { guard let mode = tokenSuffix(value, after: "security_mode_") else { return nil } - let insecure: (String, String?) -> ValueExplanation = { summary, action in + let networkName: String = isCurrentNetwork ? "the network this Mac is connected to" : "this nearby network" + let subject: String = isCurrentNetwork ? "The network this Mac is connected to" : "This nearby network" + let weak: (String, String, String) -> ValueExplanation = { summary, detail, why in isCurrentNetwork - ? .review(summary, action: action, confidence: .documented) - : .info(summary, confidence: .documented) + ? .review( + summary, + detail: detail, + why: why, + action: "If this is your router, switch it to WPA2/WPA3 Personal (or WPA3 Personal). Otherwise, prefer another network or use a VPN.", + confidence: .documented + ) + : .info( + summary, + detail: detail, + why: "\(why) It's only a nearby network, so it doesn't affect this Mac unless it joins.", + action: "Nothing to do unless you plan to join it.", + confidence: .documented + ) + } + let strong: (String, String, String) -> ValueExplanation = { summary, detail, why in + .normal( + summary, + detail: detail, + why: why, + action: "Nothing to do.", + confidence: .documented + ) } switch mode { case "wpa3_personal": - return .normal("WPA3 Personal, the newest and strongest security for home networks.", confidence: .documented) + return strong( + "WPA3 Personal, the newest and strongest security for home networks.", + "Traffic on \(networkName) is encrypted with WPA3, which resists password-guessing attacks better than WPA2.", + "It's the security type Apple recommends for Wi-Fi routers." + ) case "wpa3_transition": - return .normal( + return strong( "WPA2/WPA3 Personal: WPA3 for devices that support it, and WPA2 for older ones.", - confidence: .documented + "\(subject) accepts both WPA3 and WPA2, so each device uses the best one it supports.", + "It's the mode Apple recommends for routers that still have older devices on them." ) case "wpa2_personal": - return .normal( + return strong( "WPA2 Personal: secure and widely used. WPA3 is newer, if the router supports it.", - confidence: .documented + "Traffic on \(networkName) is encrypted with WPA2 and a shared password.", + "WPA2 is secure with a strong password. WPA3 adds protection against password guessing." + ) + case "wpa2_personal_mixed": + return weak( + "WPA/WPA2 Personal: the router still accepts the outdated original WPA.", + "\(subject) allows both WPA2 and the original WPA, which is no longer considered secure.", + "Allowing the original WPA weakens the network and can slow it down." ) case "wpa2_enterprise", "wpa3_enterprise", "wpa2_wpa3_enterprise": - return .normal( + return strong( "Enterprise security: each person signs in with their own account, as is common at work or school.", - confidence: .documented + "\(subject) checks each person's own user name and password or certificate instead of a shared password.", + "Each person's traffic is encrypted separately, and the organization can remove one person's access without changing a shared password." + ) + case "wpa2_enterprise_mixed": + return weak( + "WPA/WPA2 Enterprise: the network still accepts the outdated original WPA.", + "Each person signs in with their own account, but the network also allows the original WPA.", + "Allowing the original WPA weakens the network's encryption." ) case "wpa_personal", "wpa_personal_mixed", "wpa_enterprise": - return insecure( + return weak( "The original WPA, an outdated security type.", - "If this is your router, switch it to WPA2/WPA3 Personal." + "\(subject) uses the first version of WPA, from 2003.", + "Its encryption has known weaknesses, and Apple recommends against it." ) - case "wep": - return insecure( + case "wep", "wep40", "wep128", "8021x": + let wepDetail: String = switch mode { + case "wep40": "\(subject) uses WEP encryption with a 40-bit key." + case "wep128": "\(subject) uses WEP encryption with a 128-bit key." + case "8021x": "\(subject) uses 802.1X sign-in with WEP encryption." + default: "\(subject) uses WEP encryption." + } + return weak( "WEP, an obsolete security type that can be broken quickly.", - "If this is your router, switch it to WPA2/WPA3 Personal." + wepDetail, + "WEP can be cracked in minutes, so it offers almost no protection." + ) + case "wps": + return weak( + "Wi-Fi Protected Setup (WPS), a push-button or PIN way of joining.", + "\(subject) was advertising WPS when the scan ran. macOS doesn't use WPS to join networks.", + "The WPS PIN method can be guessed, which can reveal the network's password." ) case "none": - return insecure( + return weak( "An open network with no Wi-Fi encryption, so others nearby can see traffic that isn't otherwise protected.", - "Prefer secured networks. Websites using HTTPS and VPNs still protect their own traffic." + "\(subject) has no password and no Wi-Fi encryption.", + "Anyone nearby can see traffic that isn't protected in another way. Websites using HTTPS and VPNs still protect their own traffic." ) case "owe": - return .normal("Enhanced Open: no password, but traffic is still encrypted.", confidence: .documented) + return strong( + "Enhanced Open: no password, but traffic is still encrypted.", + "\(subject) is open to anyone, but each device's traffic is encrypted separately.", + "It protects against others nearby reading your traffic, though it can't prove the network is the one you expect." + ) default: return nil } } +private func wifiNetworkTypeExplanation(_ value: String) -> ValueExplanation? { + switch tokenSuffix(value, after: "network_type_") { + case "station": + .info( + "A regular network hosted by a router or access point.", + detail: "Apple calls this an infrastructure network: devices connect through a central router or access point.", + why: "It's the usual kind of Wi-Fi network at home, at work, and in public places.", + action: "Nothing to do.", + confidence: .documented + ) + case "ibss": + .info( + "A direct computer-to-computer (ad hoc) network.", + detail: "Devices connect directly to each other without a router. Current macOS can no longer create these networks.", + why: "Ad hoc networks usually have weak or no security and don't provide internet access on their own.", + action: "If you don't recognize it, don't join it.", + confidence: .documented + ) + case "sharing": + .info( + "A network created by Internet Sharing on a Mac.", + detail: "A Mac is sharing its internet connection over Wi-Fi, acting as a small router.", + why: "Other devices get internet access through that Mac, which must stay awake and connected.", + action: "If it's this Mac and you didn't mean to share, turn off Internet Sharing in System Settings › General › Sharing.", + confidence: .documented + ) + default: + nil + } +} + /// Signal bands are common Wi-Fi guidance, not an Apple specification. func wifiSignalExplanation(_ value: String, isCurrentNetwork: Bool) -> ValueExplanation? { let numbers: [Int] = value @@ -647,14 +826,30 @@ func wifiSignalExplanation(_ value: String, isCurrentNetwork: Bool) -> ValueExpl let summary: String = "\(quality) signal (\(signal) dBm\(noiseNote))." guard isCurrentNetwork else { - return .info(summary, detail: detail) + return .info( + summary, + detail: detail, + why: "This is how strongly a nearby network reached this Mac. It only matters if you plan to join that network.", + action: "Nothing to do.", + confidence: .observed + ) } - let action: String? = status == .normal - ? nil + let why: String = status == .normal + ? "A strong signal gives the fastest, steadiest connection this network can offer." + : "A weak signal lowers speed and can make the connection drop, especially for video calls." + let action: String = status == .normal + ? "Nothing to do." : "Move closer to the router or access point, or reduce obstacles between them, for faster and steadier Wi-Fi." - return ValueExplanation(summary: summary, detail: detail, status: status, confidence: .observed, suggestedAction: action) + return ValueExplanation( + summary: summary, + detail: detail, + significance: why, + status: status, + confidence: .observed, + suggestedAction: action + ) } /// Common Wi-Fi guidance for received signal strength in dBm. @@ -690,13 +885,17 @@ func wifiChannelExplanation(_ value: String) -> ValueExplanation? { .first { $0.lowercased().hasSuffix("mhz") } .map { "\($0.dropLast(3)) MHz" } let bandNote: String + let why: String if lowercased.contains("6ghz") { bandNote = "The 6 GHz band (Wi-Fi 6E and later) is the fastest and least crowded, with the shortest range." + why = "It gives the most speed close to the router, but walls weaken it quickly." } else if lowercased.contains("5ghz") { bandNote = "The 5 GHz band is faster than 2.4 GHz, with a shorter range." + why = "It's the usual choice for speed in the same room or nearby rooms." } else if lowercased.contains("2ghz") { bandNote = "The 2.4 GHz band reaches farther but is slower and more crowded." + why = "It's shared with many other networks and devices, such as Bluetooth and microwave ovens, so it's often slower." } else { return nil } @@ -706,6 +905,8 @@ func wifiChannelExplanation(_ value: String) -> ValueExplanation? { return .info( "Channel \(channel) on the \(band) band\(width.map { ", \($0) wide" } ?? "").", detail: "\(bandNote) Wider channels carry more data but are more sensitive to interference.", + why: why, + action: "Nothing to do. The router chooses the channel; if Wi-Fi is slow, letting it choose automatically usually works best.", confidence: .documented ) } @@ -739,15 +940,63 @@ private func wifiRegionExplanation(_ code: String) -> ValueExplanation? { return .info( "Wi-Fi region: \(region). It sets which channels and transmit power are allowed.", + detail: "macOS works out the country from nearby routers and, when allowed, Location Services.", + why: "Each country allows different Wi-Fi channels and power levels, so the region decides which networks and bands the Mac can use.", + action: "Nothing to do. If it names the wrong country, some networks may be hidden; turning Wi-Fi off and on usually makes macOS check again.", confidence: .documented ) } -private func wifiCapabilityExplanation(_ value: String, feature: String) -> ValueExplanation? { +private func wifiLocaleExplanation(_ value: String) -> ValueExplanation? { + let summary: String + let confidence: ValueConfidence + + switch value.uppercased() { + case "FCC": + summary = "Wi-Fi follows the United States (FCC) rules for channels and transmit power." + confidence = .documented + case "ETSI": + summary = "Wi-Fi follows the European (ETSI) rules for channels and transmit power." + confidence = .documented + case "MKK", "JAPAN": + summary = "Wi-Fi follows the Japanese (MKK) rules for channels and transmit power." + confidence = .documented + case "ROW": + summary = "Wi-Fi follows a general set of rules for channels and transmit power used outside specific regions." + confidence = .observed + default: + return nil + } + + return .info( + summary, + detail: "The locale is the group of radio rules the Wi-Fi hardware applies. It's set from the Wi-Fi country code.", + why: "It decides which channels and power levels the Mac may use, so a network on a channel outside these rules won't appear.", + action: "Nothing to do.", + confidence: confidence + ) +} + +private func wifiCapabilityExplanation(_ value: String, feature: String, why: String) -> ValueExplanation? { switch decodeBooleanLike(value) { - case true?: .info("This Mac's Wi-Fi supports \(feature).") - case false?: .info("This Mac's Wi-Fi doesn't support \(feature).") - case nil: nil + case true?: + .info( + "This Mac's Wi-Fi supports \(feature).", + detail: "The Wi-Fi hardware reports that it can do this.", + why: why, + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "This Mac's Wi-Fi doesn't support \(feature).", + detail: "The Wi-Fi hardware reports that it can't do this, usually because it's older or not made by Apple.", + why: why, + action: "Nothing to do, unless you need this feature on this Mac.", + confidence: .documented + ) + case nil: + nil } } diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index 62a14d2..fed26d2 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -49,7 +49,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr /// Every value the app explains for fields with a limited set of values. let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples - + networkValueSamples + softwareHistoryAndFirewallValueSamples + + networkValueSamples + softwareHistoryAndFirewallValueSamples + wifiValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -217,6 +217,47 @@ private let softwareHistoryAndFirewallValueSamples: [ValueSample] = { return samples }() +private let wifiValueSamples: [ValueSample] = { + let interface: [String] = ["spairport_airport_interfaces", "[]"] + let current: [String] = interface + ["spairport_current_network_information"] + let nearby: [String] = interface + ["spairport_airport_other_local_wireless_networks", "[]"] + + var samples: [ValueSample] = [ + "spairport_status_connected", "spairport_status_off", "spairport_status_disassociated", + "spairport_status_inactive", "spairport_status_disconnected", "spairport_status_not_associated" + ].map { ValueSample(.wifi, interface + ["spairport_status_information"], $0) } + + let securityModes: [String] = [ + "none", "wep", "wep40", "wep128", "8021x", "wps", "wpa_personal", "wpa_personal_mixed", "wpa_enterprise", + "wpa2_personal", "wpa2_personal_mixed", "wpa2_enterprise", "wpa2_enterprise_mixed", "wpa3_personal", + "wpa3_transition", "wpa3_enterprise", "wpa2_wpa3_enterprise", "owe" + ] + for network in [current, nearby] { + samples += securityModes.map { ValueSample(.wifi, network + ["spairport_security_mode"], "spairport_security_mode_\($0)") } + samples += ["-45 dBm / -91 dBm", "-80 dBm / -91 dBm"].map { ValueSample(.wifi, network + ["spairport_signal_noise"], $0) } + samples += ["36 (5GHz, 160MHz)", "6 (2GHz, 20MHz)", "37 (6GHz, 320MHz)"].map { + ValueSample(.wifi, network + ["spairport_network_channel"], $0) + } + samples.append(ValueSample(.wifi, network + ["spairport_network_phymode"], "802.11ax")) + } + samples += ["station", "ibss", "sharing"].map { + ValueSample(.wifi, nearby + ["spairport_network_type"], "spairport_network_type_\($0)") + } + samples.append(ValueSample(.wifi, current + ["spairport_network_rate"], scalar: .integer(1201))) + samples.append(ValueSample(.wifi, current + ["spairport_network_country_code"], "US")) + samples.append(ValueSample(.wifi, interface + ["spairport_wireless_country_code"], "DE")) + samples.append(ValueSample(.wifi, interface + ["spairport_supported_phymodes"], "802.11 a/b/g/n/ac/ax")) + samples += ["FCC", "ETSI", "MKK", "RoW"].map { ValueSample(.wifi, interface + ["spairport_wireless_locale"], $0) } + + for field in ["spairport_caps_airdrop", "spairport_caps_autounlock", "spairport_caps_wow", "spairport_caps_awdl"] { + samples += ["spairport_caps_supported", "spairport_caps_unsupported"].map { + ValueSample(.wifi, interface + [field], $0) + } + } + + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { @@ -345,6 +386,36 @@ struct ValueCatalogTests { #expect(allowAll.summary.contains("off")) } + // MARK: - Wi-Fi + + @Test + func weakWiFiSecurityMattersOnlyForTheCurrentNetwork() throws { + func explain(_ mode: String, current: Bool) -> ValueExplanation? { + wifiSecurityExplanation("spairport_security_mode_\(mode)", isCurrentNetwork: current) + } + + for mode in ["wep40", "wep128", "8021x", "wps", "wpa2_personal_mixed", "wpa2_enterprise_mixed"] { + #expect(explain(mode, current: true)?.status == .worthReviewing, "\(mode)") + #expect(explain(mode, current: false)?.status == .informational, "\(mode)") + } + + let nearby = try #require(explain("none", current: false)) + #expect(nearby.suggestedAction == "Nothing to do unless you plan to join it.") + #expect(explain("wpa3_personal", current: false)?.status == .normal) + } + + @Test + func wiFiStatusSpellingsFromAppleAreRecognized() throws { + let disassociated = try #require(valueExplanation( + dataType: .wifi, + path: ["spairport_status_information"], + scalar: .string("spairport_status_disassociated") + )) + + #expect(disassociated.summary == "Wi-Fi is on but not connected to a network.") + #expect(valueExplanation(dataType: .wifi, path: ["spairport_status_information"], scalar: .string("spairport_status_future"))?.status == .unknown) + } + @Test func processorFamilyComesFromTheHardwareOverview() { let appleSilicon: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "chip_type": .string("Apple M1")])] diff --git a/docs/value-explanations.md b/docs/value-explanations.md index 6fba260..dd4951f 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -188,3 +188,47 @@ Mac User Guide (). | `spfirewall_applications` | `spfirewall_allow_local` | Info | Apple | Apple key | | `spfirewall_stealthenabled` | `Yes`, `No` | Normal, Info | Apple | seen (`Yes`) | | `spfirewall_loggingenabled` | `Yes`, `No` | Info | Standard | seen (`No`) | + +## Wi-Fi + +Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's +`SPAirPortReporter` strings. Security types follow Apple's "Recommended settings +for Wi-Fi routers and access points" (). +Published `system_profiler SPAirPortDataType` output shows the locales `ETSI` +and `RoW` (for example and +). Signal-strength bands +are common Wi-Fi guidance, not an Apple specification. + +Security modes are Worth a look only on the network this Mac is connected to; +on a nearby network the same value is Info. + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `spairport_status_information` | `spairport_status_connected` | Normal | Apple | seen | +| `spairport_status_information` | `spairport_status_off` | Info | Apple | Apple key | +| `spairport_status_information` | `spairport_status_disassociated` ("Not Associated") | Info | Apple | Apple key | +| `spairport_status_information` | `spairport_status_inactive` ("Network Service Inactive") | Info | Apple | Apple key | +| `spairport_status_information` | `spairport_status_disconnected`, `spairport_status_not_associated` | Info | Apple | unconfirmed | +| `spairport_security_mode` | `wpa3_personal` | Normal | Apple | seen | +| `spairport_security_mode` | `wpa3_transition` (reported as `pairport_security_mode_wpa3_transition`) | Normal | Apple | seen | +| `spairport_security_mode` | `wpa2_personal`, `wpa2_enterprise` | Normal | Apple | seen | +| `spairport_security_mode` | `wpa3_enterprise`, `wpa2_wpa3_enterprise`, `owe` | Normal | Apple | unconfirmed | +| `spairport_security_mode` | `wpa2_personal_mixed`, `wpa2_enterprise_mixed` | Worth a look / Info | Apple | Apple key | +| `spairport_security_mode` | `wpa_personal`, `wpa_enterprise` | Worth a look / Info | Apple | Apple key | +| `spairport_security_mode` | `wpa_personal_mixed` | Worth a look / Info | Apple | unconfirmed | +| `spairport_security_mode` | `wep`, `wep40`, `wep128`, `8021x` (802.1X with WEP) | Worth a look / Info | Apple | Apple key | +| `spairport_security_mode` | `wps` | Worth a look / Info | Apple | Apple key | +| `spairport_security_mode` | `none` | Worth a look / Info | Apple | Apple key | +| `spairport_network_type` | `spairport_network_type_station` (Infrastructure) | Info | Apple | seen | +| `spairport_network_type` | `spairport_network_type_ibss` (Computer-to-Computer) | Info | Apple | Apple key | +| `spairport_network_type` | `spairport_network_type_sharing` (Wi-Fi Internet Sharing) | Info | Apple | Apple key | +| `spairport_caps_airdrop`, `_autounlock`, `_wow`, `_awdl` | `spairport_caps_supported` | Info | Apple | seen (not `_awdl`) | +| `spairport_caps_airdrop`, `_autounlock`, `_wow`, `_awdl` | `spairport_caps_unsupported` | Info | Apple | Apple key | +| `spairport_wireless_locale` | `FCC` | Info | Apple | seen | +| `spairport_wireless_locale` | `ETSI`, `RoW` | Info | Apple (`ETSI`), Standard (`RoW`) | published | +| `spairport_wireless_locale` | `MKK` | Info | Apple | unconfirmed | +| `spairport_signal_noise` | any `-NN dBm / -NN dBm` (Excellent to Poor) | Normal to Worth a look (current), Info (nearby) | Standard | seen (values withheld) | +| `spairport_network_channel` | any `N (2GHz/5GHz/6GHz, NNMHz)` | Info | Apple | seen (values withheld) | +| `spairport_network_phymode`, `spairport_supported_phymodes` | `802.11` lists up to `be` | Normal (current), Info | Apple | seen (values withheld) | +| `spairport_network_rate` | a number of Mbps | Info | Apple | seen | +| `spairport_network_country_code`, `spairport_wireless_country_code` | two-letter country codes | Info | Apple | seen (`US`) | From b6ccf907b0ea0baea6f1da2703e393e30698c67d Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:01:09 +0000 Subject: [PATCH 09/15] Explain every battery condition, power setting, and scheduled event Adds Apple's Fair (Replace Soon) and Check Battery (Service Battery) conditions, which the battery rule missed, and the UPS Power source. Every power value now says what it means, why it matters, and what to check. --- PLAN.md | 2 +- .../Values/PowerAndStorageValueRules.swift | 390 ++++++++++++++---- .../ValueCatalogTests.swift | 62 +++ docs/value-explanations.md | 33 ++ 4 files changed, 416 insertions(+), 71 deletions(-) diff --git a/PLAN.md b/PLAN.md index cefc802..7d6174f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -86,7 +86,7 @@ test run. 7. [x] Install history, legacy software, and firewall. 8. [x] Wi-Fi: status, security, network type, capabilities, regulatory locale. -9. [ ] Power and battery: battery condition and charge states, power +9. [x] Power and battery: battery condition and charge states, power source, hibernate mode, Low and High Power Mode, network reachability, reduced brightness, adapter, UPS, scheduled events. 10. [ ] Storage and NVMe: SMART, file system, medium, partition map, diff --git a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift index 806c9c4..0a39485 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift @@ -2,6 +2,18 @@ import Foundation // MARK: - Power +// Sources: the values seen in docs/value-inventory.md (TRUE, FALSE, Good, wake, and the +// withheld 0/1 power settings) and the keys in Apple's SPPowerReporter strings: TRUE and +// FALSE ("Yes"/"No"); battery condition Good ("Normal"), Fair ("Replace Soon"), Poor +// ("Replace Now"), and Check Battery ("Service Battery"); event types wake, poweron, +// wakepoweron, sleep, shutdown, and restart; and the AC Power, Battery Power, and UPS +// Power groups. Current macOS shows the condition as Normal or Service Recommended in +// System Settings, and older menus as Replace Soon, Replace Now, or Service Battery +// (https://support.apple.com/guide/mac-help/mh20865 and +// https://support.apple.com/en-us/108376); those spellings are matched too but are +// unconfirmed in system_profiler output. Hibernate modes come from the pmset man page. +// Battery cycle and capacity limits come from https://support.apple.com/en-us/102888. + /// Apple designs current Mac notebook batteries for 1,000 charge cycles. let batteryDesignCycleCount: Int = 1_000 @@ -14,11 +26,29 @@ let powerValueRules: [ValueRule] = [ ValueRule(.power, field: "sppower_battery_at_warn_level") { context in switch decodeBooleanLike(context.reportedValue) { case true? where decodeBooleanLike(context.sibling("sppower_battery_is_charging") ?? "") == true: - .info("The battery was low when the scan ran, and it was charging.") + .info( + "The battery was low when the scan ran, and it was charging.", + detail: "The charge was below the level where macOS warns about low battery, but a power adapter was charging it.", + why: "Charging will bring it back up, so there's no risk of the Mac shutting down.", + action: "Nothing to do.", + confidence: .documented + ) case true?: - .review("The battery was low when the scan ran.", action: "Connect power soon.") + .review( + "The battery was low when the scan ran.", + detail: "The charge was below the level where macOS warns about low battery, and it wasn't charging.", + why: "If the battery runs out, the Mac goes to sleep and unsaved work can be lost.", + action: "Connect power soon.", + confidence: .documented + ) case false?: - .normal("The battery wasn't low when the scan ran.") + .normal( + "The battery wasn't low when the scan ran.", + detail: "The charge was above the level where macOS warns about low battery.", + why: "The Mac had enough charge to keep running on battery for now.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -32,22 +62,48 @@ let powerValueRules: [ValueRule] = [ let charging: Bool = decodeBooleanLike(context.sibling("sppower_battery_is_charging") ?? "") == true if percent <= lowBatteryPercent, !charging { - return .review("The battery was at \(percent)% when the scan ran.", action: "Connect power soon.") + return .review( + "The battery was at \(percent)% when the scan ran.", + detail: "That's \(lowBatteryPercent)% or less, and the battery wasn't charging.", + why: "The Mac will soon go to sleep to protect the battery, and unsaved work could be lost.", + action: "Connect power soon.", + confidence: .documented + ) } - return .normal("The battery was at \(percent)%\(charging ? " and charging" : "") when the scan ran.") + return .normal( + "The battery was at \(percent)%\(charging ? " and charging" : "") when the scan ran.", + detail: "This is how full the battery was, as a share of what it can hold now (not when it was new).", + why: "It's a snapshot; the charge changes all the time.", + action: "Nothing to do.", + confidence: .documented + ) }, ValueRule(.power, field: "sppower_battery_is_charging") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - return .normal("The battery was charging.") + return .normal( + "The battery was charging.", + detail: "A power adapter was connected and charging the battery when the scan ran.", + why: "It's a snapshot of that moment.", + action: "Nothing to do.", + confidence: .documented + ) case false? where decodeBooleanLike(context.sibling("sppower_battery_fully_charged") ?? "") == true: - return .normal("The battery wasn't charging because it was full.") + return .normal( + "The battery wasn't charging because it was full.", + detail: "macOS stops charging once the battery is full, even with the adapter connected.", + why: "That's how macOS protects the battery.", + action: "Nothing to do.", + confidence: .documented + ) case false?: return .info( "The battery wasn't charging when the scan ran.", detail: "That's expected on battery power. On a power adapter, macOS can pause charging to protect the battery, for example with Optimized Battery Charging.", + why: "If a power adapter was connected and the battery stays low, the adapter or the battery may have a problem.", + action: "Nothing to do on battery. If it doesn't charge on a power adapter, try another adapter or cable and check the battery condition.", confidence: .documented ) case nil: @@ -57,29 +113,29 @@ let powerValueRules: [ValueRule] = [ ValueRule(.power, field: "sppower_battery_fully_charged") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .normal("The battery was fully charged.") - case false?: .info("The battery wasn't fully charged.") - case nil: nil - } - }, - - ValueRule(.power, field: "sppower_battery_health") { context in - let value: String = context.reportedValue.lowercased() - - if value == "good" || value == "normal" { - return .normal("The battery reports a normal condition.", confidence: .documented) - } - - if value.contains("service") || value.contains("replace") || value == "poor" { - return .review( - "The battery reports that it needs service (“\(context.reportedValue)”).", - detail: "It may hold less charge than when it was new, or behave unexpectedly.", - action: "Keep backups current and contact Apple or an Apple Authorized Service Provider.", + case true?: + .normal( + "The battery was fully charged.", + detail: "The battery had reached its full charge when the scan ran.", + why: "It's a snapshot of that moment.", + action: "Nothing to do.", confidence: .documented ) + case false?: + .info( + "The battery wasn't fully charged.", + detail: "The battery was below its full charge when the scan ran. Optimized Battery Charging can hold it at 80% for a while.", + why: "It's a snapshot of that moment, not a problem on its own.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil } + }, - return nil + ValueRule(.power, field: "sppower_battery_health") { context in + batteryConditionExplanation(context.reportedValue) }, ValueRule(.power, field: "sppower_battery_cycle_count", unrecognizedValues: .ignore) { context in @@ -91,6 +147,8 @@ let powerValueRules: [ValueRule] = [ return .info( "\(cycles.formatted()) charge cycles, at or beyond the \(batteryDesignCycleCount.formatted()) cycles Mac notebook batteries are designed for.", detail: "A battery past its rated cycles still works, but usually holds less charge. Check the battery condition and maximum capacity.", + why: "Batteries wear with use, so an older battery gives shorter battery life.", + action: "Check the battery condition and maximum capacity. Replace the battery if battery life no longer meets your needs.", confidence: .documented ) } @@ -98,6 +156,8 @@ let powerValueRules: [ValueRule] = [ return .normal( "\(cycles.formatted()) charge cycles, within the \(batteryDesignCycleCount.formatted()) cycles Mac notebook batteries are designed for.", detail: "One cycle is using 100% of the battery's charge in total, even if that happens across several days.", + why: "Batteries wear with use, and this one is within its designed life.", + action: "Nothing to do.", confidence: .documented ) }, @@ -110,12 +170,20 @@ let powerValueRules: [ValueRule] = [ if percent < batteryDesignCapacityPercent { return .review( "The battery holds \(percent)% of its original capacity, below the \(batteryDesignCapacityPercent)% Apple designs batteries to keep.", + detail: "A full charge now lasts noticeably less than when the battery was new.", + why: "Battery life is shorter, and macOS may recommend service.", action: "Expect shorter battery life. Battery service can restore it.", confidence: .documented ) } - return .normal("The battery holds \(percent)% of its original capacity.", confidence: .documented) + return .normal( + "The battery holds \(percent)% of its original capacity.", + detail: "This compares what a full charge holds now with what it held when new.", + why: "It's at or above the \(batteryDesignCapacityPercent)% Apple designs batteries to keep, so battery life is close to normal.", + action: "Nothing to do.", + confidence: .documented + ) }, ValueRule(.power, field: "Current Power Source", unrecognizedValues: .ignore) { context in @@ -124,33 +192,45 @@ let powerValueRules: [ValueRule] = [ } switch context.parentKey { - case "Battery Power": return .info("This Mac was running on battery when the scan ran.") - case "AC Power": return .info("This Mac was running on its power adapter when the scan ran.") - default: return nil - } - }, - - ValueRule(.power, field: "Hibernate Mode") { context in - switch leadingInteger(context.reportedValue) { - case 3: - .normal( - "Safe sleep: memory stays powered and is also saved to disk, the default for Mac notebooks.", + case "Battery Power": + return .info( + "This Mac was running on battery when the scan ran.", + detail: "The settings in this Battery Power group were the ones in use.", + why: "Battery settings usually save energy, for example by sleeping sooner.", + action: "Nothing to do.", + confidence: .documented + ) + case "AC Power": + return .info( + "This Mac was running on its power adapter when the scan ran.", + detail: "The settings in this AC Power group were the ones in use.", + why: "Power adapter settings usually favor performance and staying awake.", + action: "Nothing to do.", + confidence: .documented + ) + case "UPS Power": + return .info( + "This Mac was running on a UPS (backup power supply) when the scan ran.", + detail: "The settings in this UPS Power group were the ones in use, which usually means wall power had failed.", + why: "A UPS only lasts a short time, and macOS can shut the Mac down when it runs low.", + action: "Save your work. If wall power is back, check the UPS and its connection.", confidence: .documented ) - case 0: - .normal("Memory stays powered during sleep and isn't saved to disk, the default for Mac desktops.", confidence: .documented) - case 25: - .info("Memory is saved to disk and powered off during sleep, which saves battery but wakes more slowly.", confidence: .documented) default: - nil + return nil } }, + ValueRule(.power, field: "Hibernate Mode") { context in + hibernateModeExplanation(context.reportedValue) + }, + ValueRule(.power, field: "Display Sleep Timer", unrecognizedValues: .ignore) { context in sleepTimerExplanation( context.reportedValue, timed: "The display turns off after", - never: "The display doesn't turn off automatically on this power source." + never: "The display doesn't turn off automatically on this power source.", + why: "Turning the display off sooner saves energy and, on a notebook, battery." ) }, @@ -159,7 +239,8 @@ let powerValueRules: [ValueRule] = [ context.reportedValue, timed: "The Mac can go to sleep after about", never: "The Mac doesn't go to sleep automatically on this power source.", - detail: "macOS also waits for the display to turn off, and apps such as media players or downloads can keep it awake longer." + detail: "macOS also waits for the display to turn off, and apps such as media players or downloads can keep it awake longer.", + why: "Sleeping saves energy, but a sleeping Mac can't run downloads, backups, or shared services." ) }, @@ -168,28 +249,118 @@ let powerValueRules: [ValueRule] = [ context.reportedValue, timed: "Hard disks spin down after", never: "Hard disks don't spin down automatically on this power source.", - detail: "This only affects spinning hard drives; solid-state drives have no disks to spin down." + detail: "This only affects spinning hard drives; solid-state drives have no disks to spin down.", + why: "Spinning down saves energy and noise, but the next access waits a few seconds for the disk." ) } ] +private func batteryConditionExplanation(_ value: String) -> ValueExplanation? { + let serviceAction: String = "Keep backups current and contact Apple or an Apple Authorized Service Provider." + + switch value.lowercased() { + case "good", "normal": + return .normal( + "The battery reports a normal condition.", + detail: "macOS shows this as Normal: the battery is working as expected.", + why: "Battery life should be close to what this Mac is designed for.", + action: "Nothing to do.", + confidence: .documented + ) + case "fair", "replace soon": + return .review( + "The battery reports that it will need replacing soon (“\(value)”).", + detail: "Apple's System Information shows this as Replace Soon: the battery works but holds less charge than when it was new.", + why: "Battery life is shorter than new, and it will keep getting shorter.", + action: "Plan a battery replacement. Keep backups current.", + confidence: .documented + ) + case "poor", "replace now": + return .review( + "The battery reports that it needs service (“\(value)”).", + detail: "Apple's System Information shows this as Replace Now: the battery holds much less charge than when it was new.", + why: "Battery life is much shorter, and the Mac may slow down or shut down unexpectedly on battery.", + action: serviceAction, + confidence: .documented + ) + case "service recommended": + return .review( + "The battery reports that it needs service (“\(value)”).", + detail: "Apple says the battery is working normally, but holds noticeably less charge than when it was new.", + why: "Battery life is shorter than new. It's safe to keep using the Mac.", + action: "If battery life no longer meets your needs, contact Apple or an Apple Authorized Service Provider about a battery replacement.", + confidence: .documented + ) + case "check battery", "service battery": + return .review( + "The battery reports that it needs service (“\(value)”).", + detail: "Apple's System Information shows this as Service Battery: the battery isn't working as expected, even if it still holds a charge.", + why: "Battery life may be short, and the battery may behave unexpectedly.", + action: serviceAction, + confidence: .documented + ) + default: + return nil + } +} + +private func hibernateModeExplanation(_ value: String) -> ValueExplanation? { + switch leadingInteger(value) { + case 3: + .normal( + "Safe sleep: memory stays powered and is also saved to disk, the default for Mac notebooks.", + detail: "The Mac wakes quickly from memory, and the copy on disk protects open work if the battery runs out.", + why: "It's the setting Apple ships on notebooks, balancing fast wake with safety.", + action: "Nothing to do.", + confidence: .documented + ) + case 0: + .normal( + "Memory stays powered during sleep and isn't saved to disk, the default for Mac desktops.", + detail: "The Mac wakes quickly and doesn't write memory to disk when it sleeps.", + why: "It's the setting Apple ships on desktops. On a notebook it means open work is lost if the battery runs out during sleep.", + action: "Nothing to do on a desktop. On a notebook, the default is 3; this was likely changed with pmset.", + confidence: .documented + ) + case 25: + .info( + "Memory is saved to disk and powered off during sleep, which saves battery but wakes more slowly.", + detail: "This mode can only be set with pmset in Terminal.", + why: "Sleep uses almost no battery, but waking takes longer and writes more to the disk.", + action: "Nothing to do if you chose this. Apple recommends against changing hibernation settings otherwise.", + confidence: .documented + ) + default: + nil + } +} + private func sleepTimerExplanation( _ value: String, timed: String, never: String, - detail: String? = nil + detail: String? = nil, + why: String ) -> ValueExplanation? { guard let minutes = leadingInteger(value) else { return nil } if minutes == 0 { - return .info(never, detail: detail, confidence: .documented) + return .info( + never, + detail: detail ?? "A value of 0 means never.", + why: why, + action: "Nothing to do if you chose this. You can change it in System Settings › Battery or Energy.", + confidence: .documented + ) } return .info( "\(timed) \(minutes) \(minutes == 1 ? "minute" : "minutes") of inactivity.", - detail: detail, + detail: detail ?? "The timer counts minutes without keyboard, mouse, or trackpad use.", + why: why, + action: "Nothing to do. You can change it in System Settings › Battery, Energy, or Lock Screen.", confidence: .documented ) } @@ -214,10 +385,19 @@ let powerSettingValueRules: [ValueRule] = [ case true?: .info( "Low Power Mode is on \(source): macOS uses less energy, which can make the Mac a little slower.", + detail: "macOS lowers processor speed and screen brightness and cuts background activity to save energy.", + why: "Battery lasts longer and the Mac runs cooler and quieter, at some cost to speed.", + action: "Nothing to do if you chose this. You can change it in System Settings › Battery.", confidence: .documented ) case false?: - .normal("Low Power Mode is off \(source).", confidence: .documented) + .normal( + "Low Power Mode is off \(source).", + detail: "The Mac runs at normal performance \(source).", + why: "This is the default.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -231,10 +411,18 @@ let powerSettingValueRules: [ValueRule] = [ .info( "High Power Mode is on \(source): the Mac can run its fans faster to keep up performance in demanding work.", detail: "Only some Mac models offer this mode.", + why: "Long, heavy tasks such as video exports can finish sooner, but the fans may be louder and the battery drains faster.", + action: "Nothing to do if you chose this. You can change it in System Settings › Battery.", confidence: .documented ) case false?: - .normal("High Power Mode is off \(source).", confidence: .documented) + .normal( + "High Power Mode is off \(source).", + detail: "The Mac uses its normal balance of performance, fan noise, and energy \(source). Only some Mac models offer High Power Mode.", + why: "This is the default.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -247,10 +435,19 @@ let powerSettingValueRules: [ValueRule] = [ case true?: .info( "The Mac stays reachable on the network instead of sleeping fully \(source), which uses more energy.", - detail: "This keeps network services such as file or screen sharing available while the display is off." + detail: "This keeps network services such as file or screen sharing available while the display is off.", + why: "Other devices can reach the Mac while it would otherwise sleep, at the cost of more energy.", + action: "Nothing to do if you share files or the screen from this Mac. Otherwise you can turn it off in System Settings › Energy or Battery › Options.", + confidence: .documented ) case false?: - .normal("The Mac can sleep fully \(source) instead of staying reachable on the network.") + .normal( + "The Mac can sleep fully \(source) instead of staying reachable on the network.", + detail: "Network services such as file or screen sharing may not answer while the Mac sleeps.", + why: "Sleeping fully saves the most energy. This is the default.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -258,28 +455,67 @@ let powerSettingValueRules: [ValueRule] = [ ValueRule(.power, field: "ReduceBrightness") { context in switch decodeSettingFlag(context.reportedValue) { - case true?: .info("The display dims slightly on battery to save energy.", confidence: .documented) - case false?: .info("The display doesn't dim automatically on battery.", confidence: .documented) - case nil: nil + case true?: + .info( + "The display dims slightly on battery to save energy.", + detail: "macOS lowers the brightness a little when the Mac switches to battery.", + why: "The display is one of the biggest uses of battery, so this makes the battery last longer.", + action: "Nothing to do. You can change it in System Settings › Battery › Options.", + confidence: .documented + ) + case false?: + .info( + "The display doesn't dim automatically on battery.", + detail: "Brightness stays where you set it when the Mac switches to battery.", + why: "The battery drains a little faster than with dimming on.", + action: "Nothing to do if you prefer it. You can change it in System Settings › Battery › Options.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.power, field: "sppower_battery_charger_connected") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .info("A power adapter was connected when the scan ran.") - case false?: .info("No power adapter was connected when the scan ran, so the Mac was running on battery.") - case nil: nil + case true?: + .info( + "A power adapter was connected when the scan ran.", + detail: "The Mac was drawing power from an adapter.", + why: "It's a snapshot of that moment.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "No power adapter was connected when the scan ran, so the Mac was running on battery.", + detail: "The Mac wasn't drawing power from an adapter.", + why: "It's a snapshot of that moment. The battery was supplying power.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.power, field: "sppower_ups_installed") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .info("macOS detected a UPS (backup power supply) that it can monitor.", confidence: .documented) + .info( + "macOS detected a UPS (backup power supply) that it can monitor.", + detail: "A UPS is connected with a data cable, so macOS can see its charge.", + why: "macOS can shut the Mac down safely before the UPS runs out during a power cut.", + action: "Nothing to do. You can set when the Mac shuts down in System Settings › Energy › UPS.", + confidence: .documented + ) case false?: .info( "macOS didn't detect a UPS (backup power supply).", - detail: "A UPS connected only for power, without a USB data cable, doesn't appear here." + detail: "A UPS connected only for power, without a USB data cable, doesn't appear here.", + why: "Most Macs don't use one. Without it, a power cut turns off a desktop Mac at once.", + action: "Nothing to do.", + confidence: .documented ) case nil: nil @@ -287,18 +523,32 @@ let powerSettingValueRules: [ValueRule] = [ }, ValueRule(.power, field: "eventtype") { context in - switch context.reportedValue.lowercased() { - case "wake": .info("A scheduled wake: the Mac wakes from sleep at this time.", confidence: .documented) - case "poweron": .info("A scheduled start: the Mac turns on at this time if it's off.", confidence: .documented) - case "wakepoweron": .info("A scheduled wake or start: the Mac wakes or turns on at this time.", confidence: .documented) - case "sleep": .info("A scheduled sleep: the Mac goes to sleep at this time.", confidence: .documented) - case "shutdown": .info("A scheduled shutdown: the Mac shuts down at this time.", confidence: .documented) - case "restart": .info("A scheduled restart: the Mac restarts at this time.", confidence: .documented) - default: nil - } + scheduledPowerEventExplanation(context.reportedValue) } ] +private func scheduledPowerEventExplanation(_ value: String) -> ValueExplanation? { + let summary: String + + switch value.lowercased() { + case "wake": summary = "A scheduled wake: the Mac wakes from sleep at this time." + case "poweron": summary = "A scheduled start: the Mac turns on at this time if it's off." + case "wakepoweron": summary = "A scheduled wake or start: the Mac wakes or turns on at this time." + case "sleep": summary = "A scheduled sleep: the Mac goes to sleep at this time." + case "shutdown": summary = "A scheduled shutdown: the Mac shuts down at this time." + case "restart": summary = "A scheduled restart: the Mac restarts at this time." + default: return nil + } + + return .info( + summary, + detail: "macOS, an app, or you scheduled this event. Scheduled By names who asked for it.", + why: "It explains why the Mac may wake, turn on, sleep, or restart on its own. macOS schedules short wakes itself, for example for maintenance and updates.", + action: "Nothing to do if you recognize who scheduled it. Otherwise, check the app named in Scheduled By.", + confidence: .documented + ) +} + // MARK: - Storage let lowFreeSpaceFraction: Double = 0.10 diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index fed26d2..fdb9071 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -50,6 +50,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr /// Every value the app explains for fields with a limited set of values. let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples + networkValueSamples + softwareHistoryAndFirewallValueSamples + wifiValueSamples + + powerValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -258,6 +259,50 @@ private let wifiValueSamples: [ValueSample] = { return samples }() +private let powerValueSamples: [ValueSample] = { + let charge: [String] = ["sppower_battery_charge_info"] + let health: [String] = ["sppower_battery_health_info"] + let charging: [String: ProfileValue] = ["sppower_battery_is_charging": .string("TRUE")] + let full: [String: ProfileValue] = ["sppower_battery_fully_charged": .string("TRUE")] + + var samples: [ValueSample] = [ + ValueSample(.power, charge + ["sppower_battery_at_warn_level"], "TRUE"), + ValueSample(.power, charge + ["sppower_battery_at_warn_level"], "TRUE", siblings: charging), + ValueSample(.power, charge + ["sppower_battery_at_warn_level"], "FALSE"), + ValueSample(.power, charge + ["sppower_battery_state_of_charge"], scalar: .integer(4)), + ValueSample(.power, charge + ["sppower_battery_state_of_charge"], scalar: .integer(67)), + ValueSample(.power, charge + ["sppower_battery_is_charging"], "TRUE"), + ValueSample(.power, charge + ["sppower_battery_is_charging"], "FALSE"), + ValueSample(.power, charge + ["sppower_battery_is_charging"], "FALSE", siblings: full), + ValueSample(.power, charge + ["sppower_battery_fully_charged"], "TRUE"), + ValueSample(.power, charge + ["sppower_battery_fully_charged"], "FALSE"), + ValueSample(.power, health + ["sppower_battery_cycle_count"], scalar: .integer(154)), + ValueSample(.power, health + ["sppower_battery_cycle_count"], scalar: .integer(1_200)), + ValueSample(.power, health + ["sppower_battery_health_maximum_capacity"], "97%"), + ValueSample(.power, health + ["sppower_battery_health_maximum_capacity"], "72%") + ] + samples += ["Good", "Fair", "Poor", "Check Battery", "Normal", "Service Recommended"].map { + ValueSample(.power, health + ["sppower_battery_health"], $0) + } + samples += ["AC Power", "Battery Power", "UPS Power"].map { ValueSample(.power, [$0, "Current Power Source"], "TRUE") } + samples += ["0", "3", "25"].map { ValueSample(.power, ["AC Power", "Hibernate Mode"], $0) } + + for timer in ["Display Sleep Timer", "System Sleep Timer", "Disk Sleep Timer"] { + samples += [Int64(0), 10].map { ValueSample(.power, ["AC Power", timer], scalar: .integer($0)) } + } + for setting in ["LowPowerMode", "HighPowerMode", "PrioritizeNetworkReachabilityOverSleep", "ReduceBrightness"] { + samples += [Int64(0), 1].map { ValueSample(.power, ["Battery Power", setting], scalar: .integer($0)) } + } + for field in ["sppower_battery_charger_connected", "sppower_ups_installed"] { + samples += ["TRUE", "FALSE"].map { ValueSample(.power, [field], $0) } + } + samples += ["wake", "poweron", "wakepoweron", "sleep", "shutdown", "restart"].map { + ValueSample(.power, ["_items", "[]", "_items", "[]", "eventtype"], $0) + } + + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { @@ -416,6 +461,23 @@ struct ValueCatalogTests { #expect(valueExplanation(dataType: .wifi, path: ["spairport_status_information"], scalar: .string("spairport_status_future"))?.status == .unknown) } + // MARK: - Power + + @Test + func everyAppleBatteryConditionIsExplained() throws { + func explain(_ value: String) -> ValueExplanation? { + valueExplanation(dataType: .power, path: ["sppower_battery_health_info", "sppower_battery_health"], scalar: .string(value)) + } + + #expect(explain("Good")?.status == .normal) + for value in ["Fair", "Poor", "Check Battery"] { + #expect(explain(value)?.status == .worthReviewing, "\(value)") + } + #expect(explain("Fair")?.detail?.contains("Replace Soon") == true) + #expect(explain("Check Battery")?.detail?.contains("Service Battery") == true) + #expect(explain("Excellent")?.status == .unknown) + } + @Test func processorFamilyComesFromTheHardwareOverview() { let appleSilicon: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "chip_type": .string("Apple M1")])] diff --git a/docs/value-explanations.md b/docs/value-explanations.md index dd4951f..91f44f3 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -232,3 +232,36 @@ on a nearby network the same value is Info. | `spairport_network_phymode`, `spairport_supported_phymodes` | `802.11` lists up to `be` | Normal (current), Info | Apple | seen (values withheld) | | `spairport_network_rate` | a number of Mbps | Info | Apple | seen | | `spairport_network_country_code`, `spairport_wireless_country_code` | two-letter country codes | Info | Apple | seen (`US`) | + +## Power and battery + +Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's +`SPPowerReporter` strings. Battery conditions: "Check the condition of your Mac +laptop's battery" () and "If +you see battery Service Recommended" (). +Cycle and capacity limits: . Hibernate +modes: the `pmset` man page. + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `sppower_battery_health` | `Good` (shown as Normal) | Normal | Apple | seen | +| `sppower_battery_health` | `Fair` (Replace Soon) | Worth a look | Apple | Apple key | +| `sppower_battery_health` | `Poor` (Replace Now) | Worth a look | Apple | Apple key | +| `sppower_battery_health` | `Check Battery` (Service Battery) | Worth a look | Apple | Apple key | +| `sppower_battery_health` | `Normal`, `Service Recommended`, `Replace Soon`, `Replace Now`, `Service Battery` | Normal, Worth a look | Apple | unconfirmed (shown in System Settings and menus) | +| `sppower_battery_at_warn_level` | `TRUE` (charging or not), `FALSE` | Info or Worth a look, Normal | Apple | seen (`TRUE`), Apple key (`FALSE`) | +| `sppower_battery_is_charging` | `TRUE`, `FALSE` (full or not) | Normal, Info | Apple | seen (`FALSE`), Apple key | +| `sppower_battery_fully_charged` | `TRUE`, `FALSE` | Normal, Info | Apple | seen (`FALSE`), Apple key | +| `sppower_battery_charger_connected` | `TRUE`, `FALSE` | Info | Apple | seen (`FALSE`), Apple key | +| `sppower_ups_installed` | `TRUE`, `FALSE` | Info | Apple | seen (`FALSE`), Apple key | +| `sppower_battery_state_of_charge` | a percentage (10% or less and not charging is Worth a look) | Normal, Worth a look | Apple | seen | +| `sppower_battery_cycle_count` | a number (1,000 or more is Info) | Normal, Info | Apple | seen | +| `sppower_battery_health_maximum_capacity` | a percentage (below 80% is Worth a look) | Normal, Worth a look | Apple | seen (withheld) | +| `Current Power Source` | `TRUE` under `AC Power`, `Battery Power`, `UPS Power` | Info | Apple | Apple key | +| `Hibernate Mode` | `0`, `3`, `25` | Normal, Info | Apple | seen (`3`) | +| `Display`, `System`, `Disk Sleep Timer` | minutes, `0` (never) | Info | Apple | seen | +| `LowPowerMode`, `HighPowerMode` | `0`/`1` (or yes/no) | Normal, Info | Apple | seen (withheld) | +| `PrioritizeNetworkReachabilityOverSleep` | `0`/`1` | Normal, Info | Apple | seen (withheld) | +| `ReduceBrightness` | `0`/`1` | Info | Apple | seen (withheld) | +| `eventtype` | `wake` | Info | Apple | seen | +| `eventtype` | `poweron`, `wakepoweron`, `sleep`, `shutdown`, `restart` | Info | Apple | Apple key | From 0312c3e7a962219a2a2c387506fddd619516d2f7 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:03:59 +0000 Subject: [PATCH 10/15] Explain every storage, partition, and drive health value Adds Apple's Apple Partition Map, NTFS read-only volumes, SATA TRIM, and the common partition types, and gives every storage value what it means, why it matters, and what to check. --- PLAN.md | 2 +- .../Values/PowerAndStorageValueRules.swift | 367 +++++++++++++++--- .../SettingsAndSoftwareValueRules.swift | 122 +++++- .../ValueCatalogTests.swift | 41 +- docs/value-explanations.md | 34 ++ 5 files changed, 486 insertions(+), 80 deletions(-) diff --git a/PLAN.md b/PLAN.md index 7d6174f..585572e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -89,7 +89,7 @@ test run. 9. [x] Power and battery: battery condition and charge states, power source, hibernate mode, Low and High Power Mode, network reachability, reduced brightness, adapter, UPS, scheduled events. -10. [ ] Storage and NVMe: SMART, file system, medium, partition map, +10. [x] Storage and NVMe: SMART, file system, medium, partition map, writable, internal, protocol, ownership, TRIM, removable, detachable, volume content. 11. [ ] Startup security, software overview and hardware: Secure Boot and diff --git a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift index 0a39485..dbd9d15 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/PowerAndStorageValueRules.swift @@ -551,6 +551,16 @@ private func scheduledPowerEventExplanation(_ value: String) -> ValueExplanation // MARK: - Storage +// Sources: the values seen in docs/value-inventory.md (APFS, ssd, Verified, yes/no, +// unknown_partition_map_type, guid_partition_map_type, Apple Fabric, Disk Image) and +// the keys in Apple's SPStorageReporter and SPSupport strings: ssd and rotational; +// Verified, Failing, and Not Supported; guid_, master_boot_record_, apple_, and +// unknown_partition_map_type; Journaled HFS+ and Case-sensitive Journaled HFS+. The +// other file system and protocol names are unconfirmed and matched by name. File +// system facts come from the Disk Utility User Guide +// (https://support.apple.com/guide/disk-utility/dsku19ed921c) and the Signed System +// Volume from https://support.apple.com/guide/security/secd698747c9. + let lowFreeSpaceFraction: Double = 0.10 let storageValueRules: [ValueRule] = [ @@ -561,17 +571,37 @@ let storageValueRules: [ValueRule] = [ ValueRule(.storage, field: "writable") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - return .normal("Files can be written to this volume.") + return .normal( + "Files can be written to this volume.", + detail: "The volume is mounted with write access.", + why: "You and your apps can save and change files on it, subject to the usual permissions.", + action: "Nothing to do.", + confidence: .documented + ) case false? where context.sibling("mount_point") == "/": return .normal( "Read-only by design: macOS seals its system volume so it can't be changed.", detail: "This is the Signed System Volume. Your files live on a separate, writable data volume.", + why: "Sealing the system stops malware and mistakes from changing macOS itself.", + action: "Nothing to do.", confidence: .documented ) case false? where context.nestedSibling("physical_drive", "protocol") == "Disk Image": - return .normal("A read-only disk image, which is normal for installers and downloaded apps.") + return .normal( + "A read-only disk image, which is normal for installers and downloaded apps.", + detail: "Most downloaded disk images are made read-only so their contents can't be changed.", + why: "It keeps the installer or app exactly as its developer made it.", + action: "Nothing to do. Copy the app to Applications and eject the disk image when you're done.", + confidence: .documented + ) case false?: - return .info("This volume is mounted read-only, so files on it can't be changed.") + return .info( + "This volume is mounted read-only, so files on it can't be changed.", + detail: "Common reasons are a drive formatted as NTFS (which macOS can read but not write), a locked SD card, or a disk with errors.", + why: "You can open files on it, but you can't save, change, or delete them.", + action: "If you need to write to it, check the card's lock switch, run First Aid in Disk Utility, or reformat it after backing it up.", + confidence: .documented + ) case nil: return nil } @@ -596,7 +626,10 @@ let storageValueRules: [ValueRule] = [ context.report.storageMountPoints.contains("/System/Volumes/Data") { return .info( "\(percent) free (\(amounts)).", - detail: "The system volume shares its space with the data volume, which shows the same free space." + detail: "The system volume shares its space with the data volume, which shows the same free space.", + why: "Any shortage is reported once, on the data volume.", + action: "Nothing to do here; check the data volume instead.", + confidence: .documented ) } @@ -604,75 +637,184 @@ let storageValueRules: [ValueRule] = [ return .review( "Only \(percent) free (\(amounts)).", detail: "When a startup disk is nearly full, the Mac can slow down and macOS updates may not install.", - action: "Free up space in System Settings › General › Storage." + why: "macOS needs free space for updates, virtual memory, and temporary files.", + action: "Free up space in System Settings › General › Storage.", + confidence: .observed ) } - let sharedSpaceNote: String? = ["/", "/System/Volumes/Data"].contains(context.sibling("mount_point") ?? "") + let sharedSpaceNote: String = ["/", "/System/Volumes/Data"].contains(context.sibling("mount_point") ?? "") ? "The system and data volumes share the same disk space, so both report the same free space." - : nil + : "This is the space left on the volume when the scan ran." - return .normal("\(percent) free (\(amounts)).", detail: sharedSpaceNote) + return .normal( + "\(percent) free (\(amounts)).", + detail: sharedSpaceNote, + why: "There's enough room for updates and everyday use.", + action: "Nothing to do.", + confidence: .observed + ) }, ValueRule(.storage, field: "medium_type") { context in switch context.reportedValue.lowercased() { - case "ssd": .info("A solid-state drive (flash storage, no moving parts).") - case "rotational": .info("A spinning hard drive.") - default: nil + case "ssd": + .info( + "A solid-state drive (flash storage, no moving parts).", + detail: "The drive stores data on flash memory chips.", + why: "SSDs are fast and quiet, and they don't need defragmenting.", + action: "Nothing to do.", + confidence: .documented + ) + case "rotational": + .info( + "A spinning hard drive.", + detail: "The drive stores data on spinning magnetic disks.", + why: "Hard drives are slower than SSDs, and their moving parts wear out over time.", + action: "Nothing to do. Keep backups current, especially as the drive ages.", + confidence: .documented + ) + default: + nil } }, ValueRule(.storage, field: "file_system") { context in - let value: String = context.reportedValue.lowercased() + fileSystemExplanation(context.reportedValue) + }, - if value == "apfs" { - return .normal("APFS, the standard Mac file system since macOS High Sierra.", confidence: .documented) - } + ValueRule(.storage, .nvme, field: "partition_map_type") { context in + partitionMapExplanation(context.reportedValue) + } +] - if value.contains("hfs") { - return .info("Mac OS Extended (HFS+), the older Mac file system, common on older and backup drives.", confidence: .documented) - } +private func fileSystemExplanation(_ reported: String) -> ValueExplanation? { + let value: String = reported.lowercased() + let caseNote: String = value.contains("case-sensitive") + ? " It's case-sensitive, so “File” and “file” are different names, which some apps don't expect." + : "" - if value.contains("exfat") { - return .info("exFAT, which both Macs and Windows PCs can read and write. Common on USB drives and SD cards.", confidence: .documented) - } + if value.hasPrefix("apfs") { + return .normal( + "APFS, the standard Mac file system since macOS High Sierra.", + detail: "Apple File System is built for SSDs, with snapshots, space sharing between volumes, and built-in encryption.\(caseNote)", + why: "It's what macOS needs for its startup disk, and it supports Time Machine and FileVault.", + action: "Nothing to do.", + confidence: .documented + ) + } - if value.contains("msdos") || value.contains("fat32") { - return .info("FAT32 (MS-DOS), an old format readable almost everywhere, limited to files under 4 GB.", confidence: .documented) - } + if value.contains("hfs") { + return .info( + "Mac OS Extended (HFS+), the older Mac file system, common on older and backup drives.", + detail: "Mac OS Extended was the Mac's standard file system before APFS.\(caseNote)", + why: "It still works well on hard drives, but a Mac with Apple silicon can't start up from it.", + action: "Nothing to do for a backup or data drive. Use APFS if you format a drive for a current Mac.", + confidence: .documented + ) + } - return nil - }, + if value.contains("exfat") { + return .info( + "exFAT, which both Macs and Windows PCs can read and write. Common on USB drives and SD cards.", + detail: "exFAT has no journal, so a drive unplugged without ejecting can lose data.", + why: "It's the best choice for sharing a drive with Windows, but it's less resilient than APFS.", + action: "Always eject the drive before unplugging it.", + confidence: .documented + ) + } - ValueRule(.storage, .nvme, field: "partition_map_type") { context in - switch context.reportedValue { - case "guid_partition_map_type": - .normal("GUID partition map, the standard layout for Mac disks.", confidence: .documented) - case "master_boot_record_partition_map_type": - .info("Master Boot Record, an older layout common on drives formatted for Windows PCs.", confidence: .documented) - case "unknown_partition_map_type": - .info( - "macOS didn't report a partition layout for this device.", - confidence: .likely(reasons: [ - "This value appears for the internal SSD on Apple silicon Macs and for mounted disk images, which macOS manages differently from ordinary disks." - ]) - ) - default: - nil - } + if value.contains("msdos") || value.contains("ms-dos") || value.contains("fat32") { + return .info( + "FAT32 (MS-DOS), an old format readable almost everywhere, limited to files under 4 GB.", + detail: "FAT32 works with cameras, older devices, and nearly every computer, but it can't store a file of 4 GB or more.", + why: "Large video files and disk images won't fit on it.", + action: "Nothing to do unless you need to store large files. Then use exFAT or APFS.", + confidence: .documented + ) } -] + + if value.contains("ntfs") { + return .info( + "NTFS, the Windows file system. macOS can read it but can't write to it.", + detail: "macOS mounts NTFS volumes read-only.", + why: "You can open files from it, but you can't save or change files on it from this Mac.", + action: "To write to it from a Mac, reformat it as exFAT after backing it up.", + confidence: .documented + ) + } + + return nil +} + +private func partitionMapExplanation(_ value: String) -> ValueExplanation? { + switch value { + case "guid_partition_map_type": + .normal( + "GUID partition map, the standard layout for Mac disks.", + detail: "Apple's System Information calls this GPT (GUID Partition Table).", + why: "A Mac can start up from a disk with this layout, and it works with all current Macs.", + action: "Nothing to do.", + confidence: .documented + ) + case "master_boot_record_partition_map_type": + .info( + "Master Boot Record, an older layout common on drives formatted for Windows PCs.", + detail: "Apple's System Information calls this MBR (Master Boot Record).", + why: "It's fine for sharing data, but a Mac can't start up from it and it can't hold volumes larger than 2 TB.", + action: "Nothing to do for a data drive. Use GUID if you format a drive for a Mac.", + confidence: .documented + ) + case "apple_partition_map_type": + .info( + "Apple Partition Map, the layout used by PowerPC Macs.", + detail: "Apple's System Information calls this APM (Apple Partition Map).", + why: "Current Macs can read it, but can't start up from it.", + action: "Nothing to do for an old data drive. Use GUID if you reformat it.", + confidence: .documented + ) + case "unknown_partition_map_type": + .info( + "macOS didn't report a partition layout for this device.", + detail: "Apple's System Information shows this as Unknown.", + why: "It's expected for the built-in SSD on Apple silicon Macs and for disk images, and doesn't mean anything is wrong.", + action: "Nothing to do.", + confidence: .likely(reasons: [ + "This value appears for the internal SSD on Apple silicon Macs and for mounted disk images, which macOS manages differently from ordinary disks." + ]) + ) + default: + nil + } +} let storageConnectionValueRules: [ValueRule] = [ ValueRule(.storage, field: "is_internal_disk") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - return .info("A drive built into this Mac.") + return .info( + "A drive built into this Mac.", + detail: "The volume is on a drive inside the Mac.", + why: "It's always available, and on most Macs it holds macOS and your data.", + action: "Nothing to do.", + confidence: .documented + ) case false? where context.sibling("protocol") == "Disk Image": - return .info("Not a physical drive: a disk image file opened as a volume.") + return .info( + "Not a physical drive: a disk image file opened as a volume.", + detail: "A disk image is a file that macOS opens as if it were a drive.", + why: "It's often an installer or a downloaded app, and it goes away when you eject it.", + action: "Nothing to do. Eject it when you're done.", + confidence: .documented + ) case false?: - return .info("An external drive connected to this Mac.") + return .info( + "An external drive connected to this Mac.", + detail: "The volume is on a drive connected by a cable or card slot.", + why: "External drives can be unplugged, so eject them first to avoid losing data.", + action: "Nothing to do. Eject it before unplugging it.", + confidence: .documented + ) case nil: return nil } @@ -688,10 +830,18 @@ let storageConnectionValueRules: [ValueRule] = [ .info( "Ownership is ignored on this volume, so anyone using this Mac can open and change its files.", detail: "This is the Ignore ownership on this volume option in the Finder's Get Info window. It's common for external drives shared between Macs.", + why: "It makes a shared drive easy to use on several Macs, but every user of this Mac can read and change everything on it.", + action: "Nothing to do for a shared drive. If it holds private files, turn the option off in Get Info.", confidence: .documented ) case false?: - .normal("File ownership and permissions are enforced on this volume.", confidence: .documented) + .normal( + "File ownership and permissions are enforced on this volume.", + detail: "macOS checks who owns each file before letting someone open or change it.", + why: "Other users of this Mac can't read or change your files unless you share them.", + action: "Nothing to do.", + confidence: .documented + ) case nil: nil } @@ -699,45 +849,122 @@ let storageConnectionValueRules: [ValueRule] = [ ValueRule(.nvme, field: "removable_media") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .info("The storage medium can be taken out of the drive, like a memory card.") - case false?: .info("The storage medium is fixed in the drive, as it is in an SSD.") - case nil: nil + case true?: + .info( + "The storage medium can be taken out of the drive, like a memory card.", + detail: "The drive reports that its medium can be removed while the drive stays connected.", + why: "Eject the medium before removing it to avoid losing data.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "The storage medium is fixed in the drive, as it is in an SSD.", + detail: "The drive reports that its storage can't be taken out separately.", + why: "This is how SSDs and hard drives work.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.nvme, field: "detachable_drive") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .info("macOS treats this drive as one that can be disconnected, like an external SSD.") - case false?: .info("macOS treats this drive as permanently connected, like a built-in SSD.") - case nil: nil + case true?: + .info( + "macOS treats this drive as one that can be disconnected, like an external SSD.", + detail: "The drive is connected in a way that lets it be unplugged, such as over Thunderbolt or USB.", + why: "Eject it before unplugging it to avoid losing data.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "macOS treats this drive as permanently connected, like a built-in SSD.", + detail: "The drive is inside the Mac or otherwise can't be unplugged.", + why: "It's always available to macOS.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil } } ] /// Explains the connection a storage device reports, such as `Apple Fabric` or `USB`. func storageProtocolExplanation(_ value: String) -> ValueExplanation? { - switch value.lowercased() { + let external: String = "External drives should be ejected before they're unplugged." + + return switch value.lowercased() { case "apple fabric": .info( "Connected over Apple Fabric, the internal connection to the built-in SSD.", + detail: "The SSD is part of the Apple silicon chip's storage system rather than a separate drive.", + why: "It's the fastest storage in the Mac, and it can't be removed or upgraded.", + action: "Nothing to do.", confidence: .likely(reasons: [ "Apple doesn't document this name. system_profiler reports it for the built-in SSD on Apple silicon Macs." ]) ) case "disk image": - .info("A disk image file opened as a volume, such as an installer or a downloaded app.") + .info( + "A disk image file opened as a volume, such as an installer or a downloaded app.", + detail: "It's a file that macOS opens as if it were a drive.", + why: "It isn't a physical drive, so its speed and health depend on the drive the file is on.", + action: "Nothing to do. Eject it when you're done.", + confidence: .documented + ) case "usb": - .info("Connected over USB.") + .info( + "Connected over USB.", + detail: "The drive is connected by USB, directly or through an adapter or hub.", + why: "USB speed depends on the port, cable, and drive. \(external)", + action: "Nothing to do. If it's slow, connect it directly to the Mac with a faster cable.", + confidence: .documented + ) case "thunderbolt": - .info("Connected over Thunderbolt.") + .info( + "Connected over Thunderbolt.", + detail: "The drive is connected by Thunderbolt.", + why: "Thunderbolt is the fastest external connection on a Mac. \(external)", + action: "Nothing to do.", + confidence: .documented + ) case "sata": - .info("Connected over SATA, the connection older internal drives use.") + .info( + "Connected over SATA, the connection older internal drives use.", + detail: "The drive uses a SATA connection, as internal drives in Intel Macs from before about 2013 do.", + why: "SATA is slower than the NVMe and Apple Fabric connections newer Macs use.", + action: "Nothing to do.", + confidence: .documented + ) case "pci-express", "pci express", "pci": - .info("Connected over PCI Express.") + .info( + "Connected over PCI Express.", + detail: "The drive connects directly over PCI Express, like the built-in SSDs in Intel Macs from about 2013 on.", + why: "PCI Express is much faster than SATA.", + action: "Nothing to do.", + confidence: .documented + ) case "nvme", "nvm express": - .info("An NVMe solid-state drive.") + .info( + "An NVMe solid-state drive.", + detail: "NVMe is the fast storage standard most modern SSDs use.", + why: "It's much faster than older SATA drives.", + action: "Nothing to do.", + confidence: .documented + ) case "secure digital", "sd": - .info("A memory card in an SD card reader.") + .info( + "A memory card in an SD card reader.", + detail: "The volume is on a removable SD card.", + why: "Memory cards are slower than SSDs and wear out with heavy use. Eject the card before removing it.", + action: "Nothing to do.", + confidence: .documented + ) default: nil } @@ -746,15 +973,29 @@ func storageProtocolExplanation(_ value: String) -> ValueExplanation? { func smartStatusExplanation(_ value: String) -> ValueExplanation? { switch value.lowercased() { case "verified": - .normal("The drive's self-check (SMART) reports no problems.", confidence: .documented) + .normal( + "The drive's self-check (SMART) reports no problems.", + detail: "The drive monitors its own health, and none of its checks had failed when the scan ran.", + why: "It's a good sign, but it can't predict every failure.", + action: "Nothing to do. Keep backups current anyway.", + confidence: .documented + ) case "failing": .review( "The drive's self-check (SMART) reports that it is failing.", + detail: "The drive's own health checks have found a problem that often comes before the drive stops working.", + why: "You could lose the data on it at any time.", action: "Back up this drive now and plan to replace it.", confidence: .documented ) case "not supported": - .info("This drive doesn't report a SMART status, which is common for external and USB drives.") + .info( + "This drive doesn't report a SMART status, which is common for external and USB drives.", + detail: "macOS can't read the drive's self-check results, often because the enclosure or adapter doesn't pass them on.", + why: "The drive may be healthy, but macOS can't warn you if it starts to fail.", + action: "Keep backups current, and use the drive maker's tools if you want to check its health.", + confidence: .documented + ) default: nil } diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift index 1bb958f..c6e9019 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift @@ -150,29 +150,121 @@ private func accessibilityFeatureRules(_ features: [(field: String, whenOn: Stri // MARK: - NVMe storage +// Sources: spnvme_trim_support and spsata_trim_support are keys in Apple's SPNVMeReporter +// and SPSerialATAReporter strings; Yes is seen in docs/value-inventory.md. The iocontent +// values Apple_APFS, Apple_APFS_ISC, and Apple_APFS_Recovery are seen in the inventory; +// the other partition types are the names `diskutil list` shows, and are unconfirmed in +// system_profiler output. The Apple silicon containers are described in Apple Platform +// Security ("Boot process for a Mac with Apple silicon"). + let nvmeValueRules: [ValueRule] = [ ValueRule(.nvme, field: "spnvme_trim_support", unrecognizedValues: .ignore) { context in - switch decodeBooleanLike(context.reportedValue) { - case true?: .normal("TRIM is on, which helps the SSD stay fast over time.", confidence: .documented) - case false?: .info("TRIM is off, so the SSD can slow down as it fills and empties over time.", confidence: .documented) - case nil: nil - } + trimExplanation(context.reportedValue) + }, + + ValueRule(.serialATA, field: "spsata_trim_support", unrecognizedValues: .ignore) { context in + trimExplanation(context.reportedValue) }, ValueRule(.nvme, field: "iocontent") { context in - switch context.reportedValue { - case "Apple_APFS": - .info("An APFS container that holds macOS and your data.", confidence: .documented) - case "Apple_APFS_ISC": - .info("The iBoot System Container, which Apple silicon Macs use while starting up.", confidence: .documented) - case "Apple_APFS_Recovery": - .info("The container that holds macOS Recovery.", confidence: .documented) - default: - nil - } + partitionContentExplanation(context.reportedValue) } ] +private func trimExplanation(_ value: String) -> ValueExplanation? { + switch decodeBooleanLike(value) { + case true?: + .normal( + "TRIM is on, which helps the SSD stay fast over time.", + detail: "macOS tells the SSD which blocks are no longer in use, so it can clear them ahead of time.", + why: "Without TRIM, an SSD slows down as it fills and empties.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "TRIM is off, so the SSD can slow down as it fills and empties over time.", + detail: "macOS turns TRIM on for Apple SSDs automatically. Third-party SSDs need it turned on with the trimforce command.", + why: "Writing to the SSD can get slower as it fills up. It doesn't matter for hard drives.", + action: "If this is a third-party SSD, check its maker's advice about TRIM on a Mac.", + confidence: .documented + ) + case nil: + nil + } +} + +private func partitionContentExplanation(_ value: String) -> ValueExplanation? { + switch value { + case "Apple_APFS": + .info( + "An APFS container that holds macOS and your data.", + detail: "The container holds the system, data, and other APFS volumes, which share its space.", + why: "It's the main part of the startup disk.", + action: "Nothing to do.", + confidence: .documented + ) + case "Apple_APFS_ISC": + .info( + "The iBoot System Container, which Apple silicon Macs use while starting up.", + detail: "It holds startup files and security policies used before macOS loads.", + why: "The Mac needs it to start up. macOS manages it; don't change or erase it.", + action: "Nothing to do.", + confidence: .documented + ) + case "Apple_APFS_Recovery": + .info( + "The container that holds macOS Recovery.", + detail: "On Apple silicon Macs, this separate container holds the recovery system.", + why: "You need it to reinstall macOS or repair the disk when macOS won't start.", + action: "Nothing to do. Don't erase it.", + confidence: .documented + ) + case "EFI": + .info( + "The EFI system partition, used by the Mac's firmware.", + detail: "It's a small partition on GUID-formatted disks that firmware uses while starting up.", + why: "macOS manages it, and it's usually hidden.", + action: "Nothing to do.", + confidence: .observed + ) + case "Apple_HFS": + .info( + "A Mac OS Extended (HFS+) partition.", + detail: "The partition holds a volume in Mac OS Extended format.", + why: "It's common on older or backup drives. A Mac with Apple silicon can't start up from it.", + action: "Nothing to do.", + confidence: .observed + ) + case "Apple_Boot": + .info( + "A small helper partition used to start up Intel Macs.", + detail: "Intel Macs use it for recovery or to start up from encrypted or Fusion drives.", + why: "macOS manages it, and it's usually hidden.", + action: "Nothing to do.", + confidence: .observed + ) + case "Apple_CoreStorage": + .info( + "A Core Storage partition, used by older Fusion Drives and FileVault setups.", + detail: "Core Storage was the volume manager before APFS.", + why: "It usually means the disk was set up by an older version of macOS.", + action: "Nothing to do.", + confidence: .observed + ) + case "Microsoft Basic Data": + .info( + "A partition formatted for Windows or for sharing, such as exFAT, FAT32, or NTFS.", + detail: "This partition type is used for Windows volumes and for exFAT and FAT32 drives.", + why: "It can be shared with Windows PCs. macOS can't write to NTFS volumes.", + action: "Nothing to do.", + confidence: .observed + ) + default: + nil + } +} + // MARK: - Configuration profiles let configurationProfileValueRules: [ValueRule] = [ diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index fdb9071..7cd0c63 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -50,7 +50,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr /// Every value the app explains for fields with a limited set of values. let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples + networkValueSamples + softwareHistoryAndFirewallValueSamples + wifiValueSamples - + powerValueSamples + + powerValueSamples + storageValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -303,6 +303,45 @@ private let powerValueSamples: [ValueSample] = { return samples }() +private let storageValueSamples: [ValueSample] = { + let drive: [String] = ["physical_drive"] + var samples: [ValueSample] = [ + ValueSample(.storage, ["writable"], "yes"), + ValueSample(.storage, ["writable"], "no"), + ValueSample(.storage, ["writable"], "no", siblings: ["mount_point": .string("/")]), + ValueSample(.storage, ["writable"], "no", siblings: ["physical_drive": .object(["protocol": .string("Disk Image")])]), + ValueSample(.storage, ["free_space_in_bytes"], scalar: .integer(500), siblings: ["size_in_bytes": .string("1000")]), + ValueSample(.storage, ["free_space_in_bytes"], scalar: .integer(50), siblings: ["size_in_bytes": .string("1000")]), + ValueSample(.storage, drive + ["is_internal_disk"], "yes"), + ValueSample(.storage, drive + ["is_internal_disk"], "no"), + ValueSample(.storage, drive + ["is_internal_disk"], "no", siblings: ["protocol": .string("Disk Image")]), + ValueSample(.storage, ["ignore_ownership"], "yes"), + ValueSample(.storage, ["ignore_ownership"], "no") + ] + + samples += ["Verified", "Failing", "Not Supported"].map { ValueSample(.storage, drive + ["smart_status"], $0) } + samples += ["ssd", "rotational"].map { ValueSample(.storage, drive + ["medium_type"], $0) } + samples += [ + "APFS", "Journaled HFS+", "Case-sensitive Journaled HFS+", "ExFAT", "MS-DOS FAT32", "NTFS" + ].map { ValueSample(.storage, ["file_system"], $0) } + samples += [ + "guid_partition_map_type", "master_boot_record_partition_map_type", "apple_partition_map_type", "unknown_partition_map_type" + ].map { ValueSample(.storage, drive + ["partition_map_type"], $0) } + samples += [ + "Apple Fabric", "Disk Image", "USB", "Thunderbolt", "SATA", "PCI-Express", "NVMe", "Secure Digital" + ].map { ValueSample(.storage, drive + ["protocol"], $0) } + + for field in ["removable_media", "detachable_drive", "spnvme_trim_support"] { + samples += ["yes", "no"].map { ValueSample(.nvme, ["_items", "[]", field], $0) } + } + samples += ["Yes", "No"].map { ValueSample(.serialATA, ["_items", "[]", "spsata_trim_support"], $0) } + samples += [ + "Apple_APFS", "Apple_APFS_ISC", "Apple_APFS_Recovery", "EFI", "Apple_HFS", "Apple_Boot", "Apple_CoreStorage", "Microsoft Basic Data" + ].map { ValueSample(.nvme, ["_items", "[]", "volumes", "[]", "iocontent"], $0) } + + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { diff --git a/docs/value-explanations.md b/docs/value-explanations.md index 91f44f3..3b8429a 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -265,3 +265,37 @@ modes: the `pmset` man page. | `ReduceBrightness` | `0`/`1` | Info | Apple | seen (withheld) | | `eventtype` | `wake` | Info | Apple | seen | | `eventtype` | `poweron`, `wakepoweron`, `sleep`, `shutdown`, `restart` | Info | Apple | Apple key | + +## Storage and NVMe + +Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's +`SPStorageReporter`, `SPNVMeReporter`, `SPSerialATAReporter` and `SPSupport` +strings. File systems: "File system formats available in Disk Utility on Mac" +(). The sealed system +volume: "Signed system volume security" +(). + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `smart_status` | `Verified` | Normal | Apple | seen | +| `smart_status` | `Failing` | Worth a look | Apple | Apple key | +| `smart_status` | `Not Supported` | Info | Apple | Apple key | +| `medium_type` | `ssd` | Info | Apple | seen | +| `medium_type` | `rotational` | Info | Apple | Apple key | +| `file_system` | `APFS` | Normal | Apple | seen | +| `file_system` | `Journaled HFS+`, `Case-sensitive Journaled HFS+` | Info | Apple | Apple key | +| `file_system` | `ExFAT`, `MS-DOS FAT32`, `NTFS` | Info | Apple | unconfirmed (matched by name) | +| `partition_map_type` | `guid_partition_map_type` | Normal | Apple | seen | +| `partition_map_type` | `master_boot_record_partition_map_type`, `apple_partition_map_type` | Info | Apple | Apple key | +| `partition_map_type` | `unknown_partition_map_type` | Info | Inferred | seen | +| `writable` | `yes`; `no` on the system volume, a disk image, or another volume | Normal, Info | Apple | seen | +| `free_space_in_bytes` | a byte count (under 10% free is Worth a look) | Normal, Info, Worth a look | Standard | seen | +| `is_internal_disk` | `yes`, `no` (external or disk image) | Info | Apple | seen | +| `protocol` | `Apple Fabric` | Info | Inferred | seen | +| `protocol` | `Disk Image` | Info | Apple | seen | +| `protocol` | `USB`, `Thunderbolt`, `SATA`, `PCI-Express`, `NVMe`, `Secure Digital` | Info | Apple | unconfirmed | +| `ignore_ownership` | `yes`, `no` | Info, Normal | Apple | seen (`no`) | +| `removable_media`, `detachable_drive` | `yes`, `no` | Info | Apple | seen (`no`) | +| `spnvme_trim_support`, `spsata_trim_support` | `Yes`, `No` | Normal, Info | Apple | seen (`Yes`), Apple key (field) | +| `iocontent` | `Apple_APFS`, `Apple_APFS_ISC`, `Apple_APFS_Recovery` | Info | Apple | seen | +| `iocontent` | `EFI`, `Apple_HFS`, `Apple_Boot`, `Apple_CoreStorage`, `Microsoft Basic Data` | Info | Standard | unconfirmed (names `diskutil list` shows) | From 2e75dbbbd624f9f361e014090a4907fa363c3a8b Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:06:12 +0000 Subject: [PATCH 11/15] Explain every startup security, boot mode, and hardware overview value Adds Apple's installer_boot mode and gives every security level, startup protection, and overview value what it means, why it matters, and what to check. --- PLAN.md | 2 +- .../Values/StartupSecurityValueRules.swift | 88 +++++++++++++++++-- .../Values/SystemValueRules.swift | 69 +++++++++++++-- .../ValueCatalogTests.swift | 39 +++++++- docs/value-explanations.md | 32 +++++++ 5 files changed, 214 insertions(+), 16 deletions(-) diff --git a/PLAN.md b/PLAN.md index 585572e..7f50ab4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -92,7 +92,7 @@ test run. 10. [x] Storage and NVMe: SMART, file system, medium, partition map, writable, internal, protocol, ownership, TRIM, removable, detachable, volume content. -11. [ ] Startup security, software overview and hardware: Secure Boot and +11. [x] Startup security, software overview and hardware: Secure Boot and its protections, SIP, secure virtual memory, boot mode, Activation Lock. 12. [ ] Displays, audio, Bluetooth, Thunderbolt, USB, memory, card readers. 13. [ ] Settings and profiles: accessibility, language and region, diff --git a/SystemProfilerExplorer/Core/Explanations/Values/StartupSecurityValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/StartupSecurityValueRules.swift index eaab4d4..0e58fbd 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/StartupSecurityValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/StartupSecurityValueRules.swift @@ -5,6 +5,19 @@ import Foundation // The Apple Bridge section reports the startup security policy the Mac's security // controller enforces. Full Security with every protection on is the default; lower // levels can only be chosen in Startup Security Utility in macOS Recovery. +// +// Sources: Full Security and the ibridge_sb_* values Enabled and No are seen in +// docs/value-inventory.md. Full Security, Medium Security, and No Security are keys in +// Apple's SPiBridgeReporter strings (Macs with the T2 Security Chip). Reduced Security +// and Permissive Security are the Apple silicon levels named in Apple Platform Security +// ("Startup Disk security policy control for a Mac with Apple silicon", +// https://support.apple.com/guide/security/sec7d92dc49f) and "Change security settings +// on the startup disk of a Mac with Apple silicon" +// (https://support.apple.com/guide/mac-help/mchl768f7291); their system_profiler +// spelling, "Disabled" for the protections, and "Custom Configuration" are unconfirmed. + +private let restoreFullSecurity: String = + "If you didn't choose this, start up in macOS Recovery and restore Full Security in Startup Security Utility." let iBridgeValueRules: [ValueRule] = [ ValueRule(.iBridge, field: "ibridge_secure_boot") { context in @@ -13,11 +26,13 @@ let iBridgeValueRules: [ValueRule] = [ ValueRule(.iBridge, field: "ibridge_sb_sip") { context in let value: String = context.reportedValue.lowercased() + let why: String = "System Integrity Protection stops software, even with administrator rights, from changing macOS itself." if value.contains("custom") { return .review( "System Integrity Protection is only partly on (a custom configuration).", detail: "Some of its protections were turned off, which is normally done only for development or troubleshooting.", + why: why, action: "If you didn't change it deliberately, start up in macOS Recovery and run csrutil enable in Terminal.", confidence: .documented ) @@ -27,12 +42,16 @@ let iBridgeValueRules: [ValueRule] = [ case true?: return .normal( "System Integrity Protection is on in the startup security policy, which is the default.", + detail: "The security policy this Mac starts up with keeps System Integrity Protection on.", + why: why, + action: "Nothing to do.", confidence: .documented ) case false?: return .review( "System Integrity Protection is off in the startup security policy.", detail: "It is normally turned off only on purpose, for example for kernel or driver development.", + why: "\(why) Without it, malware with administrator rights can modify macOS.", action: "If you didn't turn it off deliberately, start up in macOS Recovery and run csrutil enable in Terminal.", confidence: .documented ) @@ -46,12 +65,16 @@ let iBridgeValueRules: [ValueRule] = [ case true?: .normal( "The Signed System Volume is on: macOS checks at startup that its system files are exactly as Apple signed them.", + detail: "Every read from the system volume is checked against Apple's signature.", + why: "Changed or damaged system files are caught before they can run.", + action: "Nothing to do.", confidence: .documented ) case false?: .review( "The Signed System Volume is off, so macOS doesn't check that its system files are unchanged.", detail: "This can only be turned off from macOS Recovery with System Integrity Protection off, usually for system-level development.", + why: "Changes to macOS itself, including malicious ones, wouldn't be detected.", action: "If you didn't turn it off deliberately, reinstall macOS or restore Full Security in Startup Security Utility.", confidence: .documented ) @@ -65,13 +88,18 @@ let iBridgeValueRules: [ValueRule] = [ case true?: .normal( "Kernel code is locked read-only after startup (CTRR), so it can't be changed while the Mac runs.", + detail: "Apple silicon hardware locks the kernel's memory once startup finishes.", + why: "Even an attacker who gains kernel access can't rewrite the kernel's code.", + action: "Nothing to do.", confidence: .documented ) case false?: .review( "Kernel code isn't locked read-only after startup (CTRR is off).", detail: "This protection is normally always on. It is expected to be off only with a lowered startup security policy used for kernel development.", - action: "If you didn't lower the security policy deliberately, restore Full Security in Startup Security Utility in macOS Recovery." + why: "The kernel's code could be changed while the Mac runs.", + action: "If you didn't lower the security policy deliberately, restore Full Security in Startup Security Utility in macOS Recovery.", + confidence: .documented ) case nil: nil @@ -81,12 +109,20 @@ let iBridgeValueRules: [ValueRule] = [ ValueRule(.iBridge, field: "ibridge_sb_boot_args") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .normal("Custom startup arguments (boot-args) are filtered out, which is the default.") + .normal( + "Custom startup arguments (boot-args) are filtered out, which is the default.", + detail: "The security policy ignores custom kernel startup arguments.", + why: "Startup arguments can turn off protections or change how macOS runs.", + action: "Nothing to do.", + confidence: .documented + ) case false?: .info( "Custom startup arguments (boot-args) are allowed.", detail: "Developers use them to change how the kernel starts, for example for debugging. They need a lowered startup security policy.", - action: "If you don't use custom startup arguments, restore Full Security in Startup Security Utility in macOS Recovery." + why: "Startup arguments can turn off protections or change how macOS runs.", + action: "If you don't use custom startup arguments, restore Full Security in Startup Security Utility in macOS Recovery.", + confidence: .documented ) case nil: nil @@ -96,11 +132,18 @@ let iBridgeValueRules: [ValueRule] = [ ValueRule(.iBridge, field: "ibridge_sb_other_kext") { context in switch decodeBooleanLike(context.reportedValue) { case false?: - .normal("Kernel extensions from other developers aren't allowed to load, which is the default.", confidence: .documented) + .normal( + "Kernel extensions from other developers aren't allowed to load, which is the default.", + detail: "Only Apple's own kernel extensions can load. Other developers' drivers use system extensions, which run outside the kernel.", + why: "Code running in the kernel can crash or compromise the whole Mac.", + action: "Nothing to do.", + confidence: .documented + ) case true?: .info( "Kernel extensions from other developers are allowed to load.", detail: "This is chosen in Startup Security Utility with Reduced Security, usually for older drivers such as audio interfaces or storage hardware. Each extension still needs approval before it loads.", + why: "Code running in the kernel can crash or compromise the whole Mac.", action: "If you no longer use such drivers, restore Full Security in Startup Security Utility in macOS Recovery.", confidence: .documented ) @@ -127,6 +170,9 @@ func secureBootExplanation(_ value: String) -> ValueExplanation? { if level.hasPrefix("full") { return .normal( "Full Security, the default: this Mac starts up only macOS versions that Apple currently signs and trusts.", + detail: "At startup the Mac checks that the operating system is genuine and still trusted by Apple.", + why: "It gives the strongest protection against tampered or outdated operating systems.", + action: "Nothing to do.", confidence: .documented ) } @@ -135,7 +181,8 @@ func secureBootExplanation(_ value: String) -> ValueExplanation? { return .info( "Reduced Security: this Mac can start up older signed macOS versions, and can be allowed to load kernel extensions from other developers.", detail: "It can only be chosen by an administrator in Startup Security Utility in macOS Recovery, usually for older drivers or macOS versions.", - action: "If you didn't choose it, restore Full Security in Startup Security Utility.", + why: "Older macOS versions and kernel extensions can carry security problems that Full Security would block.", + action: restoreFullSecurity, confidence: .documented ) } @@ -144,6 +191,7 @@ func secureBootExplanation(_ value: String) -> ValueExplanation? { return .review( "Permissive Security: some startup protections are turned off, such as System Integrity Protection.", detail: "This level is used for kernel and system development.", + why: "Protections that keep malware out of macOS itself are off.", action: "If you didn't choose it, turn System Integrity Protection back on and restore Full Security in macOS Recovery.", confidence: .documented ) @@ -152,7 +200,9 @@ func secureBootExplanation(_ value: String) -> ValueExplanation? { if level.hasPrefix("medium") { return .info( "Medium Security: this Mac can start up any macOS version Apple has ever signed, including ones without the latest security fixes.", - action: "If you didn't choose it, restore Full Security in Startup Security Utility in macOS Recovery.", + detail: "On a Mac with the T2 Security Chip, this is chosen in Startup Security Utility in macOS Recovery.", + why: "An older macOS without the latest fixes could be installed and started.", + action: restoreFullSecurity, confidence: .documented ) } @@ -160,7 +210,9 @@ func secureBootExplanation(_ value: String) -> ValueExplanation? { if level.hasPrefix("no security") { return .review( "No Security: this Mac doesn't check the operating system it starts up.", - action: "If you didn't choose it, restore Full Security in Startup Security Utility in macOS Recovery.", + detail: "On a Mac with the T2 Security Chip, the startup check is turned off in Startup Security Utility.", + why: "Any operating system, including a tampered one, can start up on this Mac.", + action: restoreFullSecurity, confidence: .documented ) } @@ -183,21 +235,39 @@ private func privilegedManagementExplanation( "The field name refers to privileged MDM operations, and Apple documents that device management can be allowed to manage kernel extensions and software updates on Macs with Reduced Security.", "system_profiler doesn't say which operations are allowed." ] + let why: String = "Privileged management lets an organization change low-level settings on this Mac without asking you." switch (decodeBooleanLike(value), approval) { case (false?, .person): - return .normal("No one has allowed device management (MDM) to perform privileged operations on this Mac.") + return .normal( + "No one has allowed device management (MDM) to perform privileged operations on this Mac.", + detail: "The startup security policy doesn't let a management server manage kernel extensions or software updates.", + why: why, + action: "Nothing to do.", + confidence: .likely(reasons: reasons) + ) case (false?, .enrollment): - return .normal("Automated Device Enrollment hasn't allowed device management (MDM) to perform privileged operations on this Mac.") + return .normal( + "Automated Device Enrollment hasn't allowed device management (MDM) to perform privileged operations on this Mac.", + detail: "The organization that enrolled this Mac, if any, hasn't been given these rights.", + why: why, + action: "Nothing to do.", + confidence: .likely(reasons: reasons) + ) case (true?, .person): return .info( "Someone allowed device management (MDM) to perform privileged operations on this Mac, such as managing kernel extensions and software updates.", + detail: "This was allowed by a person in Startup Security Utility.", + why: why, action: "If this Mac isn't managed by an organization you know, check System Settings › General › Device Management.", confidence: .likely(reasons: reasons) ) case (true?, .enrollment): return .info( "This Mac's organization allowed its device management (MDM) to perform privileged operations, such as managing kernel extensions and software updates, through Automated Device Enrollment.", + detail: "This is set by the organization that owns the Mac when it's enrolled.", + why: why, + action: "Nothing to do on a work or school Mac. If you own this Mac yourself, check System Settings › General › Device Management.", confidence: .likely(reasons: reasons) ) case (nil, _): diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift index 5f1f238..9f7f9db 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SystemValueRules.swift @@ -2,6 +2,10 @@ import Foundation // MARK: - Hardware +// Sources: activation_lock_enabled is seen in docs/value-inventory.md, and +// activation_lock_enabled and _disabled are keys in Apple's SPHardwareReporter strings. +// Activation Lock is described in https://support.apple.com/en-us/102541. + let hardwareValueRules: [ValueRule] = [ ValueRule(.hardware, field: "number_processors") { context in processorCountExplanation(context.reportedValue) @@ -12,11 +16,16 @@ let hardwareValueRules: [ValueRule] = [ case true?: .normal( "Activation Lock is on, so erasing and reactivating this Mac requires the owner's Apple Account.", + detail: "Find My is on, and the Mac is linked to an Apple Account.", + why: "If the Mac is lost or stolen, no one else can erase and use it without that account's password.", + action: "Nothing to do. Before you sell or give away this Mac, sign out of your Apple Account so the next owner can activate it.", confidence: .documented ) case false?: .info( "Activation Lock is off. It turns on with Find My, and is often off on managed, repaired, or resold Macs.", + detail: "The Mac isn't linked to an Apple Account for Activation Lock.", + why: "If the Mac is lost or stolen, someone else could erase it and use it.", action: "To protect this Mac if it's lost, turn on Find My in System Settings.", confidence: .documented ) @@ -33,6 +42,8 @@ let hardwareValueRules: [ValueRule] = [ return .info( "\(context.reportedValue) of unified memory, shared by the CPU and GPU.", detail: "On Apple silicon, memory is part of the chip package and can't be upgraded later.", + why: "Memory limits how many apps, browser tabs, and large files the Mac can keep open smoothly.", + action: "Nothing to do. If the Mac often slows down with many apps open, check Memory Pressure in Activity Monitor.", confidence: .documented ) } @@ -88,12 +99,19 @@ func processorCountExplanation(_ value: String) -> ValueExplanation? { return .info( "\(cores.total) CPU cores: \(cores.performance) performance and \(cores.efficiency) efficiency.", detail: "Performance cores run demanding work; efficiency cores handle background tasks using less power.", + why: "More performance cores speed up heavy work such as video exports and code builds.", + action: "Nothing to do.", confidence: .likely(reasons: reasons) ) } // MARK: - Software +// Sources: integrity_enabled, secure_vm_enabled, and normal_boot are seen in +// docs/value-inventory.md. integrity_disabled, secure_vm_disabled ("Not Enabled"), +// safe_boot, and installer_boot are keys in Apple's SPOSReporter strings. Safe Mode: +// https://support.apple.com/guide/mac-help/mh21245. + let softwareValueRules: [ValueRule] = [ ValueRule(.software, field: "system_integrity") { context in switch decodeBooleanLike(context.reportedValue) { @@ -121,9 +139,21 @@ let softwareValueRules: [ValueRule] = [ ValueRule(.software, field: "secure_vm") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .normal("Secure virtual memory is on: data macOS moves from memory to disk is encrypted.") + .normal( + "Secure virtual memory is on: data macOS moves from memory to disk is encrypted.", + detail: "When memory runs short, macOS writes some of it to a swap file, and encrypts it first.", + why: "Passwords and other secrets in memory can't be read from the disk later.", + action: "Nothing to do. Current macOS always encrypts virtual memory.", + confidence: .documented + ) case false?: - .review("Secure virtual memory is off, so data macOS moves from memory to disk isn't encrypted.") + .review( + "Secure virtual memory is off, so data macOS moves from memory to disk isn't encrypted.", + detail: "Only older macOS versions allowed turning this off; current versions always encrypt virtual memory.", + why: "Passwords and other secrets written to the swap file could be read from the disk.", + action: "Update macOS, or turn on Use secure virtual memory in Security preferences on older versions.", + confidence: .documented + ) case nil: nil } @@ -133,17 +163,35 @@ let softwareValueRules: [ValueRule] = [ let value: String = context.reportedValue.lowercased() if value == "normal_boot" { - return .normal("This Mac started up normally.") + return .normal( + "This Mac started up normally.", + detail: "macOS loaded all its usual software, including login items and extensions.", + why: "Everything runs as usual.", + action: "Nothing to do.", + confidence: .documented + ) } if value.contains("safe") { return .info( "This Mac started up in Safe Mode, which loads only essential software.", + detail: "Safe Mode checks the startup disk, skips login items and third-party extensions, and clears some caches.", + why: "Some features and apps may not work until the Mac restarts normally.", action: "Restart normally when you've finished troubleshooting.", confidence: .documented ) } + if value == "installer_boot" { + return .info( + "This Mac started up from an installer.", + detail: "Apple's System Information shows this as “Booted from installation CD/DVD”: the running system is a macOS installer, not the Mac's usual startup disk.", + why: "The report describes the installer environment, and some sections may be missing.", + action: "Nothing to do if you're installing macOS. Otherwise, choose your usual startup disk and restart.", + confidence: .documented + ) + } + return nil }, @@ -190,14 +238,25 @@ func uptimeExplanation(_ value: String) -> ValueExplanation? { let summary: String = "Running for \(uptime.duration) since the last restart." + let detail: String = "This is how long macOS had been running without a restart when the scan ran. Sleep doesn't reset it." + if uptime.days >= 30 { return .info( summary, - action: "Restarting now and then installs pending updates and clears temporary problems." + detail: detail, + why: "Some updates only take effect after a restart, and a long-running Mac can collect small problems.", + action: "Restarting now and then installs pending updates and clears temporary problems.", + confidence: .observed ) } - return .normal(summary) + return .normal( + summary, + detail: detail, + why: "The Mac has restarted recently enough that pending updates and temporary problems are unlikely to build up.", + action: "Nothing to do.", + confidence: .observed + ) } // MARK: - Firewall diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index 7cd0c63..cf56ec8 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -50,7 +50,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr /// Every value the app explains for fields with a limited set of values. let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples + networkValueSamples + softwareHistoryAndFirewallValueSamples + wifiValueSamples - + powerValueSamples + storageValueSamples + + powerValueSamples + storageValueSamples + startupAndOverviewValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -342,6 +342,32 @@ private let storageValueSamples: [ValueSample] = { return samples }() +private let startupAndOverviewValueSamples: [ValueSample] = { + var samples: [ValueSample] = [ + "Full Security", "Reduced Security", "Permissive Security", "Medium Security", "No Security" + ].map { ValueSample(.iBridge, ["ibridge_secure_boot"], $0) } + + samples += ["Enabled", "Disabled", "Custom Configuration"].map { ValueSample(.iBridge, ["ibridge_sb_sip"], $0) } + for field in ["ibridge_sb_ssv", "ibridge_sb_ctrr", "ibridge_sb_boot_args"] { + samples += ["Enabled", "Disabled"].map { ValueSample(.iBridge, [field], $0) } + } + for field in ["ibridge_sb_other_kext", "ibridge_sb_manual_mdm", "ibridge_sb_device_mdm"] { + samples += ["Yes", "No"].map { ValueSample(.iBridge, [field], $0) } + } + + samples += ["integrity_enabled", "integrity_disabled"].map { ValueSample(.software, ["system_integrity"], $0) } + samples += ["secure_vm_enabled", "secure_vm_disabled"].map { ValueSample(.software, ["secure_vm"], $0) } + samples += ["normal_boot", "safe_boot", "installer_boot"].map { ValueSample(.software, ["boot_mode"], $0) } + samples += ["up 0:1:17:52", "up 45:3:0:0"].map { ValueSample(.software, ["uptime"], $0) } + samples += ["activation_lock_enabled", "activation_lock_disabled"].map { + ValueSample(.hardware, ["activation_lock_status"], $0) + } + samples.append(ValueSample(.hardware, ["number_processors"], "proc 14:0:10:4")) + samples.append(ValueSample(.hardware, ["physical_memory"], "32 GB", siblings: ["chip_type": .string("Apple M4 Pro")])) + + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { @@ -517,6 +543,17 @@ struct ValueCatalogTests { #expect(explain("Excellent")?.status == .unknown) } + // MARK: - Startup and software overview + + @Test + func installerStartupIsExplained() throws { + let installer = try #require(valueExplanation(dataType: .software, path: ["boot_mode"], scalar: .string("installer_boot"))) + + #expect(installer.status == .informational) + #expect(installer.detail?.contains("installation CD/DVD") == true) + #expect(valueExplanation(dataType: .software, path: ["boot_mode"], scalar: .string("network_boot"))?.status == .unknown) + } + @Test func processorFamilyComesFromTheHardwareOverview() { let appleSilicon: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "chip_type": .string("Apple M1")])] diff --git a/docs/value-explanations.md b/docs/value-explanations.md index 3b8429a..b2dd6ff 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -299,3 +299,35 @@ volume: "Signed system volume security" | `spnvme_trim_support`, `spsata_trim_support` | `Yes`, `No` | Normal, Info | Apple | seen (`Yes`), Apple key (field) | | `iocontent` | `Apple_APFS`, `Apple_APFS_ISC`, `Apple_APFS_Recovery` | Info | Apple | seen | | `iocontent` | `EFI`, `Apple_HFS`, `Apple_Boot`, `Apple_CoreStorage`, `Microsoft Basic Data` | Info | Standard | unconfirmed (names `diskutil list` shows) | + +## Startup security, software overview, and hardware + +Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's +`SPiBridgeReporter`, `SPOSReporter` and `SPHardwareReporter` strings. Security +levels: "Startup Disk security policy control for a Mac with Apple silicon" +() and "Change security +settings on the startup disk of a Mac with Apple silicon" +(). Safe Mode: +. Activation Lock: +. + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `ibridge_secure_boot` | `Full Security` | Normal | Apple | seen | +| `ibridge_secure_boot` | `Medium Security` | Info | Apple | Apple key (T2 Macs) | +| `ibridge_secure_boot` | `No Security` | Worth a look | Apple | Apple key (T2 Macs) | +| `ibridge_secure_boot` | `Reduced Security` | Info | Apple | unconfirmed | +| `ibridge_secure_boot` | `Permissive Security` | Worth a look | Apple | unconfirmed | +| `ibridge_sb_sip` | `Enabled`, `Disabled`, `Custom Configuration` | Normal, Worth a look | Apple | seen (`Enabled`); others unconfirmed | +| `ibridge_sb_ssv`, `ibridge_sb_ctrr` | `Enabled`, `Disabled` | Normal, Worth a look | Apple | seen (`Enabled`); `Disabled` unconfirmed | +| `ibridge_sb_boot_args` | `Enabled` (filtered), `Disabled` | Normal, Info | Apple | seen (`Enabled`); `Disabled` unconfirmed | +| `ibridge_sb_other_kext` | `No`, `Yes` | Normal, Info | Apple | seen (`No`); `Yes` unconfirmed | +| `ibridge_sb_manual_mdm`, `ibridge_sb_device_mdm` | `No`, `Yes` | Normal, Info | Inferred | seen (`No`); `Yes` unconfirmed | +| `system_integrity` | `integrity_enabled`, `integrity_disabled` | Normal, Worth a look | Apple | seen, Apple key | +| `secure_vm` | `secure_vm_enabled`, `secure_vm_disabled` | Normal, Worth a look | Apple | seen, Apple key | +| `boot_mode` | `normal_boot` | Normal | Apple | seen | +| `boot_mode` | `safe_boot`, `installer_boot` | Info | Apple | Apple key | +| `uptime` | `up d:h:m:s` (30 days or more is Info) | Normal, Info | Standard | seen | +| `activation_lock_status` | `activation_lock_enabled`, `activation_lock_disabled` | Normal, Info | Apple | seen, Apple key | +| `number_processors` | `proc total:…:performance:efficiency` | Info | Inferred | seen | +| `physical_memory` | a size on Apple silicon | Info | Apple | seen (withheld) | From 51ec0bfeeb3faddde7cf41420ba80ea5a710b4f5 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:11:21 +0000 Subject: [PATCH 12/15] Explain every display, audio, Thunderbolt, memory, and Ethernet value Adds Apple's display types, eGPU and PCIe bus spellings, every audio transport, Thunderbolt link states and older port speeds, and memory slot and ECC states. Every value now says what it means, why it matters, and what to check. An iPhone's USB link now says Nothing to do instead of having no suggestion. --- PLAN.md | 3 +- .../Values/DisplayAndMediaValueRules.swift | 692 ++++++++++++++++-- .../Values/NetworkValueRules.swift | 98 ++- .../SecondWaveValueTests.swift | 2 +- .../ValueCatalogTests.swift | 88 +++ docs/value-explanations.md | 48 ++ 6 files changed, 851 insertions(+), 80 deletions(-) diff --git a/PLAN.md b/PLAN.md index 7f50ab4..5a9e475 100644 --- a/PLAN.md +++ b/PLAN.md @@ -94,7 +94,8 @@ test run. volume content. 11. [x] Startup security, software overview and hardware: Secure Boot and its protections, SIP, secure virtual memory, boot mode, Activation Lock. -12. [ ] Displays, audio, Bluetooth, Thunderbolt, USB, memory, card readers. +12. [x] Displays, audio, Bluetooth, Thunderbolt, USB, memory, card readers, + and Ethernet. 13. [ ] Settings and profiles: accessibility, language and region, configuration profiles, managed preferences, printers, sync services, Secure Element. diff --git a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift index 43be4be..7f3fbd5 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift @@ -2,18 +2,49 @@ import Foundation // MARK: - Graphics and displays +// Sources: the values seen in docs/value-inventory.md (spdisplays_internal, +// spdisplays_yes, spdisplays_off, spdisplays_gpu, spdisplays_builtin, +// spdisplays_metal4, sppci_vendor_Apple, spdisplays_built-in-liquid-retina-xdr) and the +// keys in Apple's SPDisplaysReporter strings: spdisplays_LCD, _CRT, _retinaLCD, +// _built-in_retinaLCD, _projector, _television, _airplaydisplay; spdisplays_internal and +// _airplay; spdisplays_yes, _no, _on, _off; spdisplays_gpu and _egpu; spdisplays_builtin, +// _pcie_device, _tb_device, and _agp_device; and the older Metal family names such as +// spdisplays_mtlgpufamilymac2. spdisplays_external and a bare spdisplays_pcie are +// unconfirmed. + let displayValueRules: [ValueRule] = [ ValueRule(.displays, field: "spdisplays_display_type") { context in - tokenSuffix(context.reportedValue, after: "spdisplays_").map { - .info("Display type: \(humanizedToken($0)).") - } + tokenSuffix(context.reportedValue, after: "spdisplays_").map(displayTypeExplanation) }, ValueRule(.displays, field: "spdisplays_connection_type") { context in switch tokenSuffix(context.reportedValue, after: "spdisplays_") { - case "internal": .info("The built-in display.") - case "external": .info("An external display.") - default: nil + case "internal": + .info( + "The built-in display.", + detail: "This is the display that's part of the Mac itself.", + why: "Its settings, such as True Tone and automatic brightness, are set in System Settings › Displays.", + action: "Nothing to do.", + confidence: .documented + ) + case "external": + .info( + "An external display.", + detail: "The display is connected with a cable, directly or through an adapter or dock.", + why: "Its resolution and refresh rate depend on the display, the cable, and the port it uses.", + action: "Nothing to do. If the picture isn't sharp or smooth, try another cable or port.", + confidence: .documented + ) + case "airplay": + .info( + "A display connected over AirPlay.", + detail: "The Mac sends its picture to this display or Apple TV over the network.", + why: "AirPlay displays can lag a little and depend on the network.", + action: "Nothing to do.", + confidence: .documented + ) + default: + nil } }, @@ -27,7 +58,13 @@ let displayValueRules: [ValueRule] = [ } let retina: String = context.reportedValue.localizedCaseInsensitiveContains("retina") ? " (Retina)" : "" - return .info("The panel has \(numbers[0].formatted()) × \(numbers[1].formatted()) pixels\(retina).") + return .info( + "The panel has \(numbers[0].formatted()) × \(numbers[1].formatted()) pixels\(retina).", + detail: "This is the number of physical pixels, not the size things look on screen.", + why: "More pixels at the same size make text and images sharper.", + action: "Nothing to do.", + confidence: .documented + ) }, ValueRule(.displays, field: "_spdisplays_resolution", unrecognizedValues: .ignore) { context in @@ -35,56 +72,231 @@ let displayValueRules: [ValueRule] = [ }, ValueRule(.displays, field: "spdisplays_mtlgpufamilysupport") { context in - guard let range = context.reportedValue.range(of: #"metal(\d+)"#, options: [.regularExpression, .caseInsensitive]) else { - return nil - } - - let version: String = String(context.reportedValue[range].dropFirst(5)) - return .info("Supports Metal \(version), Apple's graphics and compute technology.", confidence: .documented) + metalSupportExplanation(context.reportedValue) }, ValueRule(.displays, field: "spdisplays_vendor") { context in - tokenSuffix(context.reportedValue, after: "vendor_").map { .info("Made by \($0).") } + tokenSuffix(context.reportedValue, after: "vendor_").map { (vendor: String) -> ValueExplanation in + let names: [String: String] = ["amd": "AMD", "ati": "ATI", "nvidia": "NVIDIA", "intel": "Intel"] + return .info( + "Made by \(names[vendor.lowercased()] ?? vendor).", + detail: "This is the maker of the graphics processor.", + why: "It tells you whose drivers and graphics features the Mac uses.", + action: "Nothing to do.", + confidence: .documented + ) + } }, ValueRule(.displays, field: "sppci_device_type") { context in switch tokenSuffix(context.reportedValue, after: "spdisplays_") { - case "gpu": .info("A graphics processor (GPU).") - default: nil + case "gpu": + .info( + "A graphics processor (GPU).", + detail: "It draws everything on screen and speeds up graphics, video, and machine-learning work.", + why: "GPU speed matters for games, video editing, and 3D work.", + action: "Nothing to do.", + confidence: .documented + ) + case "egpu": + .info( + "An external graphics processor (eGPU) in a separate enclosure.", + detail: "It's connected over Thunderbolt. Only Intel Macs support eGPUs.", + why: "It adds graphics power, but only apps set to use it benefit.", + action: "Eject it from the menu bar before unplugging it.", + confidence: .documented + ) + default: + nil } }, ValueRule(.displays, field: "sppci_bus") { context in switch tokenSuffix(context.reportedValue, after: "spdisplays_") { - case "builtin": .info("Built into the Mac's chip, not a separate graphics card.") - case "pcie": .info("Connected over PCI Express, as a separate graphics card.") - default: nil + case "builtin": + .info( + "Built into the Mac's chip, not a separate graphics card.", + detail: "The GPU is part of the main chip and shares its memory.", + why: "It uses little power. On Apple silicon it's also fast, because it shares the chip's unified memory.", + action: "Nothing to do.", + confidence: .documented + ) + case "pcie", "pcie_device": + .info( + "Connected over PCI Express, as a separate graphics card.", + detail: "The GPU is a separate chip or card with its own memory.", + why: "Separate GPUs are faster for graphics work but use more power.", + action: "Nothing to do.", + confidence: .documented + ) + case "tb_device": + .info( + "Connected over Thunderbolt, as an external graphics processor.", + detail: "The GPU is in an external enclosure connected by Thunderbolt.", + why: "It adds graphics power to an Intel Mac. Apple silicon Macs don't support eGPUs.", + action: "Eject it from the menu bar before unplugging it.", + confidence: .documented + ) + case "agp_device": + .info( + "Connected over AGP, a graphics slot used in older Macs.", + detail: "AGP was the graphics card slot in PowerPC-era Macs.", + why: "It only appears on very old Macs.", + action: "Nothing to do.", + confidence: .documented + ) + default: + nil } }, ValueRule(.displays, field: "sppci_cores", unrecognizedValues: .ignore) { context in - leadingInteger(context.reportedValue).map { .info("\($0) GPU cores.") } + leadingInteger(context.reportedValue).map { + .info( + "\($0) GPU cores.", + detail: "The graphics processor has this many cores working in parallel.", + why: "More GPU cores speed up graphics, video effects, and machine-learning work.", + action: "Nothing to do.", + confidence: .documented + ) + } }, ValueRule(.displays, field: "spdisplays_main", unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true - ? .info("The main display, which shows the menu bar and Dock.", confidence: .documented) - : nil + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "The main display, which shows the menu bar and Dock.", + detail: "New windows and the login window appear here first.", + why: "It's the display you work on by default.", + action: "Nothing to do. You can choose the main display in System Settings › Displays › Arrange.", + confidence: .documented + ) + case false?: + .info( + "Not the main display.", + detail: "Another display shows the menu bar and Dock.", + why: "Windows open on the main display unless you move them.", + action: "Nothing to do. You can choose the main display in System Settings › Displays › Arrange.", + confidence: .documented + ) + case nil: + nil + } }, ValueRule(.displays, field: "spdisplays_mirror", unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true - ? .info("This display mirrors another display.") - : nil + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "This display mirrors another display.", + detail: "It shows the same picture as another display.", + why: "Mirroring is handy for presentations, but you can't use the displays for different windows.", + action: "Nothing to do. You can turn mirroring off in System Settings › Displays.", + confidence: .documented + ) + case false?: + .info( + "This display isn't mirroring another display.", + detail: "It shows its own picture, or it's the only display.", + why: "Each display can show different windows.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil + } }, ValueRule(.displays, field: "spdisplays_ambient_brightness", unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true - ? .info("Brightness adjusts automatically to the light around the Mac.", confidence: .documented) - : nil + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "Brightness adjusts automatically to the light around the Mac.", + detail: "A light sensor near the display adjusts brightness as the room gets lighter or darker.", + why: "It keeps the screen comfortable to read and saves energy in dim light.", + action: "Nothing to do. You can change it in System Settings › Displays.", + confidence: .documented + ) + case false?: + .info( + "Brightness doesn't adjust automatically.", + detail: "The display stays at the brightness you set, whatever the light around it.", + why: "The screen may be too bright in the dark or too dim in sunlight, and it can use more energy.", + action: "Nothing to do if you prefer it. You can change it in System Settings › Displays.", + confidence: .documented + ) + case nil: + nil + } } ] +private func displayTypeExplanation(_ token: String) -> ValueExplanation { + let lowercased: String = token.lowercased() + let known: [String: (name: String, detail: String)] = [ + "lcd": ("LCD", "A standard LCD flat-panel display."), + "crt": ("CRT", "A tube (CRT) display, as used before flat panels."), + "retinalcd": ("Retina LCD", "A Retina display: pixels are dense enough that you can't see them at a normal viewing distance."), + "built-in_retinalcd": ("Built-in Retina LCD", "The Mac's built-in Retina display: pixels are dense enough that you can't see them at a normal viewing distance."), + "projector": ("Projector", "A projector."), + "television": ("Television", "A television."), + "airplaydisplay": ("AirPlay Display", "A display the Mac reaches over AirPlay, such as an Apple TV or AirPlay-compatible TV.") + ] + let why: String = "The display type affects how sharp text looks and which color and brightness features are available." + + if let type = known[lowercased] { + return .info( + "Display type: \(type.name).", + detail: type.detail, + why: why, + action: "Nothing to do.", + confidence: .documented + ) + } + + return .info( + "Display type: \(humanizedToken(token)).", + detail: lowercased.contains("retina") + ? "Apple's name for this display. Retina displays have pixels too small to see at a normal viewing distance." + : "Apple's name for this display, as macOS reported it.", + why: why, + action: "Nothing to do.", + confidence: .observed + ) +} + +private func metalSupportExplanation(_ value: String) -> ValueExplanation? { + let why: String = "Apps and games that use Metal need a GPU that supports the version they require." + + if let range = value.range(of: #"metal(\d+)"#, options: [.regularExpression, .caseInsensitive]) { + let version: String = String(value[range].dropFirst(5)) + return .info( + "Supports Metal \(version), Apple's graphics and compute technology.", + detail: "Metal is how apps use the GPU on a Mac. Newer versions add features for games, 3D, and machine learning.", + why: why, + action: "Nothing to do. If an app says your Mac isn't supported, compare its Metal requirement with this.", + confidence: .documented + ) + } + + if let family = tokenSuffix(value, after: "mtlgpufamily") { + let name: String = family.hasPrefix("mac") + ? "macOS GPU family \(family.dropFirst(3))" + : family.hasPrefix("common") ? "common GPU family \(family.dropFirst(6))" : family + + return .info( + "Supports Metal (\(name)).", + detail: "Older versions of macOS describe Metal support by GPU family.", + why: why, + action: "Nothing to do.", + confidence: .documented + ) + } + + return nil +} + /// Decodes values such as `1512 x 982 @ 120.00Hz`, comparing them with the panel's pixels. func displayResolutionExplanation(_ value: String, pixels: String?) -> ValueExplanation? { let numbers: [Double] = value @@ -108,7 +320,13 @@ func displayResolutionExplanation(_ value: String, pixels: String?) -> ValueExpl } } - return .info("Looks like \(width.formatted()) × \(height.formatted())\(refresh).", detail: detail) + return .info( + "Looks like \(width.formatted()) × \(height.formatted())\(refresh).", + detail: detail, + why: "This is how much fits on screen. Larger sizes fit more but make text smaller.", + action: "Nothing to do. You can change it in System Settings › Displays.", + confidence: .documented + ) } /// Turns tokens such as `built-in-liquid-retina-xdr` into "Built-in Liquid Retina XDR". @@ -132,49 +350,40 @@ func humanizedToken(_ token: String) -> String { // MARK: - Audio +// Sources: coreaudio_device_type_builtin, spaudio_yes, and +// coreaudio_default_audio_system_device are seen in docs/value-inventory.md. The +// transports are keys in Apple's SPAudioReporter strings: airplay, avb, bluetooth, +// builtin, displayport, firewire, hdmi, network, other, pci, thunderbolt, unknown, usb, +// virtual, and wireless. bluetoothle and aggregate are unconfirmed. + let audioValueRules: [ValueRule] = [ ValueRule(.audio, field: "coreaudio_device_transport") { context in - guard let transport = tokenSuffix(context.reportedValue, after: "coreaudio_device_type_") else { - return nil - } - - return switch transport { - case "builtin": .info("Built into this Mac.") - case "usb": .info("Connected over USB.") - case "bluetooth", "bluetoothle": .info("Connected over Bluetooth.") - case "hdmi": .info("Connected over HDMI, usually through a display or TV.") - case "displayport": .info("Connected over DisplayPort, usually through a display.") - case "thunderbolt": .info("Connected over Thunderbolt.") - case "airplay": .info("An AirPlay device on the network.") - case "virtual": .info("A virtual device created by software, such as a recording or conferencing app.") - case "aggregate": .info("An aggregate device that combines several audio devices.") - default: nil - } + tokenSuffix(context.reportedValue, after: "coreaudio_device_type_").flatMap(audioTransportExplanation) }, ValueRule(.audio, field: "coreaudio_default_audio_input_device", unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true ? .info("The default input, used for recording unless an app chooses another.") : nil + decodeBooleanLike(context.reportedValue) == true ? defaultAudioDeviceExplanation(.input) : nil }, ValueRule(.audio, field: "coreaudio_default_audio_output_device", unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true ? .info("The default output, where sound plays unless an app chooses another.") : nil + decodeBooleanLike(context.reportedValue) == true ? defaultAudioDeviceExplanation(.output) : nil }, ValueRule(.audio, field: "coreaudio_default_audio_system_device", unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true ? .info("Plays system sounds such as alerts.") : nil + decodeBooleanLike(context.reportedValue) == true ? defaultAudioDeviceExplanation(.system) : nil }, ValueRule(.audio, field: "_properties") { context in if context.reportedValue.contains("default_audio_system_device") { - return .info("Plays system sounds such as alerts.") + return defaultAudioDeviceExplanation(.system) } if context.reportedValue.contains("default_audio_output_device") { - return .info("The default output, where sound plays unless an app chooses another.") + return defaultAudioDeviceExplanation(.output) } if context.reportedValue.contains("default_audio_input_device") { - return .info("The default input, used for recording unless an app chooses another.") + return defaultAudioDeviceExplanation(.input) } return nil @@ -186,20 +395,155 @@ let audioValueRules: [ValueRule] = [ } let kilohertz: String = (Double(rate) / 1_000).formatted(.number.precision(.fractionLength(0...1))) - return .info("Sample rate \(kilohertz) kHz.") + return .info( + "Sample rate \(kilohertz) kHz.", + detail: "The device was set to take or play this many samples per second.", + why: "48 kHz and 44.1 kHz are standard. Higher rates only help in professional recording.", + action: "Nothing to do. You can change it in Audio MIDI Setup.", + confidence: .documented + ) } ] +private enum DefaultAudioRole { + case input, output, system +} + +private func defaultAudioDeviceExplanation(_ role: DefaultAudioRole) -> ValueExplanation { + switch role { + case .input: + .info( + "The default input, used for recording unless an app chooses another.", + detail: "Apps that record sound, such as for calls or voice memos, use this device unless set otherwise.", + why: "If the wrong microphone is the default, others may not hear you well.", + action: "Nothing to do. You can choose the input in System Settings › Sound.", + confidence: .documented + ) + case .output: + .info( + "The default output, where sound plays unless an app chooses another.", + detail: "Music, videos, and calls play through this device unless an app picks another.", + why: "If sound comes from the wrong place, this is the setting to check.", + action: "Nothing to do. You can choose the output in System Settings › Sound.", + confidence: .documented + ) + case .system: + .info( + "Plays system sounds such as alerts.", + detail: "Alert sounds and sound effects play through this device.", + why: "It can differ from the output used for music and calls.", + action: "Nothing to do. You can choose it in System Settings › Sound › Sound Effects.", + confidence: .documented + ) + } +} + +private func audioTransportExplanation(_ transport: String) -> ValueExplanation? { + let summary: String + let detail: String + var confidence: ValueConfidence = .documented + + switch transport { + case "builtin": + summary = "Built into this Mac." + detail = "The Mac's own speakers, microphone, or headphone jack." + case "usb": + summary = "Connected over USB." + detail = "A USB audio device, such as a headset, microphone, or audio interface." + case "bluetooth", "bluetoothle": + summary = "Connected over Bluetooth." + detail = "Wireless headphones, speakers, or a headset." + confidence = transport == "bluetooth" ? .documented : .observed + case "hdmi": + summary = "Connected over HDMI, usually through a display or TV." + detail = "Sound goes to the display or TV along with the picture." + case "displayport": + summary = "Connected over DisplayPort, usually through a display." + detail = "Sound goes to the display's speakers along with the picture." + case "thunderbolt": + summary = "Connected over Thunderbolt." + detail = "A Thunderbolt audio interface or dock." + case "airplay": + summary = "An AirPlay device on the network." + detail = "Sound is sent over the network to a speaker, Apple TV, or other AirPlay receiver." + case "virtual": + summary = "A virtual device created by software, such as a recording or conferencing app." + detail = "It isn't hardware: an app created it to route or capture sound." + case "aggregate": + summary = "An aggregate device that combines several audio devices." + detail = "It's set up in Audio MIDI Setup to use several devices as one." + confidence = .observed + case "avb": + summary = "An AVB (Audio Video Bridging) device on the network." + detail = "Professional network audio over Ethernet." + case "network": + summary = "A network audio device." + detail = "Sound travels over the local network." + case "firewire": + summary = "Connected over FireWire." + detail = "An older FireWire audio interface, usually through an adapter." + case "pci": + summary = "Connected over PCI Express." + detail = "An audio card installed inside the Mac." + case "wireless": + summary = "A wireless audio device." + detail = "A device that connects without a cable, other than Bluetooth or AirPlay." + case "other": + summary = "Connected in another way." + detail = "macOS reported the connection as Other." + case "unknown": + summary = "macOS doesn't know how this device is connected." + detail = "macOS reported the connection as Unknown." + default: + return nil + } + + return .info( + summary, + detail: detail, + why: "How a device connects affects its delay and sound quality. Wireless connections add a little delay.", + action: "Nothing to do.", + confidence: confidence + ) +} + // MARK: - Thunderbolt and USB4 +// Sources: receptacle_no_devices_connected and "Up to 40 Gb/s" are seen in +// docs/value-inventory.md. receptacle_connected, the link states (trained, training, +// untrained, disabled, off, Loopback, and unknown, each followed by _link_status), and +// the speeds "Up to 10/20 Gb/s x1/x2" and "Up to 40 Gb/s x1" are keys in Apple's +// SPThunderboltReporter strings. The inventory withheld this Mac's link_status_key +// values, so their current format is unconfirmed. + let thunderboltValueRules: [ValueRule] = [ ValueRule(.thunderbolt, field: "receptacle_status_key") { context in switch tokenSuffix(context.reportedValue, after: "receptacle_") { - case "no_devices_connected": .info("Nothing is connected to this port.") - default: nil + case "no_devices_connected": + .info( + "Nothing is connected to this port.", + detail: "The port was empty when the scan ran.", + why: "It's free for a display, drive, dock, or charger.", + action: "Nothing to do.", + confidence: .documented + ) + case "connected": + .info( + "A device is connected to this port.", + detail: "Something was plugged into this port when the scan ran.", + why: "The device's details are listed under this port.", + action: "Nothing to do.", + confidence: .documented + ) + default: + nil } }, + ValueRule(.thunderbolt, field: "link_status_key") { context in + thunderboltLinkExplanation(context.reportedValue) + }, + ValueRule(.thunderbolt, field: "current_speed_key", unrecognizedValues: .ignore) { context in guard let speed = context.reportedValue .split(whereSeparator: { !$0.isNumber }) @@ -211,40 +555,153 @@ let thunderboltValueRules: [ValueRule] = [ let standard: String? = switch speed { case 120, 80: "Thunderbolt 5" case 40: "Thunderbolt 3, Thunderbolt 4, or USB4" + case 20: "Thunderbolt 2" + case 10: "the first Thunderbolt" default: nil } return .info( "This port supports up to \(speed) Gb/s\(standard.map { ", the speed of \($0)" } ?? "").", + detail: "This is the fastest data rate the port can use. Actual speed depends on the cable and the connected device.", + why: "Fast drives, docks, and high-resolution displays need the full speed.", + action: "Nothing to do. For full speed, use a cable rated for it.", confidence: .documented ) } ] +private func thunderboltLinkExplanation(_ value: String) -> ValueExplanation? { + guard let state = value.range(of: "_link_status", options: .backwards).map({ String(value[..<$0.lowerBound]).lowercased() }) else { + return nil + } + + return switch state { + case "trained": + .normal( + "The link is up and working.", + detail: "The port and the connected device agreed on a working connection.", + why: "Data can flow at the link's speed.", + action: "Nothing to do.", + confidence: .documented + ) + case "training": + .info( + "The link was still being set up when the scan ran.", + detail: "The port and device were negotiating a connection.", + why: "It usually finishes within a moment of plugging in.", + action: "If a device stays unusable, unplug it and plug it back in, or try another cable.", + confidence: .documented + ) + case "untrained": + .info( + "No working link has been set up on this port.", + detail: "The port hasn't established a connection with a device.", + why: "It's expected when nothing is connected. With a device plugged in, it means the connection failed.", + action: "If a device is plugged in, try another cable or port.", + confidence: .documented + ) + case "disabled", "off": + .info( + "The link is turned off.", + detail: "The port's Thunderbolt link isn't active.", + why: "Ports can turn their links off to save power when nothing needs them.", + action: "Nothing to do unless a connected device doesn't work.", + confidence: .documented + ) + case "loopback": + .info( + "The link is in loopback mode.", + detail: "The port is connected back to itself, which is used for testing.", + why: "It's unusual outside of testing.", + action: "Nothing to do unless a connected device doesn't work.", + confidence: .documented + ) + case "unknown": + .info( + "macOS doesn't know this link's state.", + detail: "The port didn't report a link state.", + why: "It doesn't mean anything is wrong.", + action: "Nothing to do unless a connected device doesn't work.", + confidence: .documented + ) + default: + nil + } +} + // MARK: - Hardware connection states and managed settings let hardwareStateValueRules: [ValueRule] = [ ValueRule(.displays, field: "spdisplays_online") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .normal("The display was on and in use when the scan ran.") - case false?: .info("The display is connected but wasn't in use when the scan ran, for example because it was off or asleep.") - case nil: nil + case true?: + .normal( + "The display was on and in use when the scan ran.", + detail: "macOS was drawing to this display.", + why: "It's available for windows and apps.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "The display is connected but wasn't in use when the scan ran, for example because it was off or asleep.", + detail: "macOS could see the display but wasn't drawing to it.", + why: "Windows can't appear on it until it wakes or turns on.", + action: "If you expected to use it, check that it's on and set to the right input.", + confidence: .documented + ) + case nil: + nil } }, + // Bluetooth transports: PCIe is seen in docs/value-inventory.md; USB and UART are unconfirmed. ValueRule(.bluetooth, field: "controller_transport") { context in - switch context.reportedValue.lowercased() { - case "pcie": .info("The Bluetooth controller is built in and connected over PCI Express.") - case "usb": .info("The Bluetooth controller is connected over USB, as in older Macs and plug-in Bluetooth adapters.") - case "uart": .info("The Bluetooth controller is built in and connected over a serial (UART) link.") - default: nil + let why: String = "It only affects how the Bluetooth chip talks to the Mac, not the range or which devices work." + + return switch context.reportedValue.lowercased() { + case "pcie": + .info( + "The Bluetooth controller is built in and connected over PCI Express.", + detail: "The Bluetooth chip is part of the Mac's combined Wi-Fi and Bluetooth module.", + why: why, + action: "Nothing to do.", + confidence: .documented + ) + case "usb": + .info( + "The Bluetooth controller is connected over USB, as in older Macs and plug-in Bluetooth adapters.", + detail: "The Bluetooth chip is connected over an internal or external USB link.", + why: why, + action: "Nothing to do.", + confidence: .documented + ) + case "uart": + .info( + "The Bluetooth controller is built in and connected over a serial (UART) link.", + detail: "The Bluetooth chip uses a simple serial connection inside the Mac.", + why: why, + action: "Nothing to do.", + confidence: .documented + ) + default: + nil } }, + // USB: Built-in is seen in docs/value-inventory.md. ValueRule(.usb, field: "USBKeyHardwareType") { context in switch context.reportedValue { - case "Built-in": .info("A USB controller built into this Mac.") - default: nil + case "Built-in": + .info( + "A USB controller built into this Mac.", + detail: "It runs the Mac's own USB or Thunderbolt ports.", + why: "Devices plugged into those ports are listed under it.", + action: "Nothing to do.", + confidence: .documented + ) + default: + nil } }, @@ -252,6 +709,56 @@ let hardwareStateValueRules: [ValueRule] = [ memoryTypeExplanation(context.reportedValue) }, + ValueRule(.memory, field: "dimm_status") { context in + memorySlotStatusExplanation(context.reportedValue) + }, + + ValueRule(.memory, field: "global_ecc_state") { context in + switch tokenSuffix(context.reportedValue, after: "ecc_") { + case "enabled": + .normal( + "ECC is on: the memory detects and corrects small errors.", + detail: "Error-correcting memory checks every read for mistakes.", + why: "It keeps rare memory errors from corrupting data or crashing the Mac.", + action: "Nothing to do.", + confidence: .documented + ) + case "disabled": + .info( + "ECC is off: the memory doesn't correct errors.", + detail: "Most Macs use memory without error correction.", + why: "It's normal for most Macs. Mac Pro and iMac Pro are the Macs that use ECC memory.", + action: "Nothing to do.", + confidence: .documented + ) + default: + nil + } + }, + + ValueRule(.memory, field: "is_memory_upgradeable") { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "The memory can be upgraded.", + detail: "This Mac has memory slots you can add to or replace.", + why: "You can add memory later if you need more.", + action: "Nothing to do. Check Apple's memory specifications for this model before buying.", + confidence: .documented + ) + case false?: + .info( + "The memory can't be upgraded.", + detail: "The memory is built in and can't be added to or replaced.", + why: "The amount you have now is what this Mac will always have.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil + } + }, + ValueRule(.cardReader, field: "spcardreader_link-speed", unrecognizedValues: .ignore) { context in cardReaderLinkExplanation(context.reportedValue) }, @@ -281,17 +788,67 @@ func memoryTypeExplanation(_ value: String) -> ValueExplanation? { if type.hasPrefix("LPDDR") { return .info( "\(value) memory, a low-power type that is built in and can't be upgraded.", + detail: "Low-power memory is soldered in place, close to the processor.", + why: "It saves energy and is fast, but the amount can't be changed later.", + action: "Nothing to do.", confidence: .documented ) } if type.hasPrefix("DDR") { - return .info("\(value) memory, a standard desktop and notebook memory type.", confidence: .documented) + return .info( + "\(value) memory, a standard desktop and notebook memory type.", + detail: "Some Macs with this type have slots for upgrading; others have it built in.", + why: "Replacement or added memory must be the same type.", + action: "Nothing to do. Check whether this model's memory is upgradeable before buying more.", + confidence: .documented + ) } return nil } +/// Memory slot states are keys in Apple's SPMemoryReporter strings: ok, empty, +/// mapped_out, and unknown. +private func memorySlotStatusExplanation(_ value: String) -> ValueExplanation? { + switch value.lowercased() { + case "ok": + .normal( + "The memory in this slot is working.", + detail: "macOS found memory in this slot and it passed its checks.", + why: "The Mac can use all of it.", + action: "Nothing to do.", + confidence: .documented + ) + case "empty": + .info( + "This memory slot is empty.", + detail: "No memory module is installed in this slot.", + why: "It's room to add memory later.", + action: "Nothing to do.", + confidence: .documented + ) + case "mapped_out": + .review( + "This memory slot was switched off (mapped out) because of errors.", + detail: "macOS found problems with the memory in this slot and stopped using it.", + why: "The Mac has less memory available than is installed, and the module may be faulty.", + action: "Reseat or replace the module in this slot, or have the Mac checked.", + confidence: .documented + ) + case "unknown": + .info( + "macOS couldn't read this memory slot's status.", + detail: "The slot didn't report whether it's working.", + why: "It doesn't mean anything is wrong.", + action: "Nothing to do unless the Mac reports less memory than expected.", + confidence: .documented + ) + default: + nil + } +} + private func cardReaderLinkExplanation(_ value: String) -> ValueExplanation? { guard decodeBooleanLike(value) == false else { return nil @@ -299,6 +856,9 @@ private func cardReaderLinkExplanation(_ value: String) -> ValueExplanation? { return .info( "The card reader's link was inactive when the scan ran.", + detail: "The reader's connection to the Mac wasn't running.", + why: "It's expected when no card is inserted. The reader starts up when you insert one.", + action: "Nothing to do. If a card isn't recognized, remove it and insert it again.", confidence: .likely(reasons: [ "Card readers usually report an inactive link when no card is inserted." ]) diff --git a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift index 46ca882..f8fd729 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/NetworkValueRules.swift @@ -393,15 +393,41 @@ let ethernetValueRules: [ValueRule] = [ return .info( "A network link to \(device) connected over USB, not a physical Ethernet adapter.", detail: "macOS creates links like this for Personal Hotspot over USB and for services such as Finder syncing and Xcode.", + why: "It appears whenever the device is plugged in and trusted, and goes away when it's unplugged.", + action: "Nothing to do.", confidence: .likely(reasons: appleDeviceLinkReasons(context, device: device)) ) } + let why: String = "How the adapter connects can limit its speed." + return switch tokenSuffix(context.reportedValue, after: "spethernet_") { - case "usb_device": .info("A USB Ethernet adapter.") - case "pcie", "pci": .info("Connected over PCI Express.") - case "builtin", "built_in": .info("Built into this Mac.") - default: nil + case "usb_device": + .info( + "A USB Ethernet adapter.", + detail: "The Ethernet port is on an adapter or dock connected over USB.", + why: "\(why) USB adapters depend on the USB port and cable they use.", + action: "Nothing to do.", + confidence: .documented + ) + case "pcie", "pci", "pci_device": + .info( + "Connected over PCI Express.", + detail: "The Ethernet controller is a chip or card connected directly over PCI Express.", + why: "\(why) PCI Express gives the controller its full speed.", + action: "Nothing to do.", + confidence: .documented + ) + case "builtin", "built_in": + .info( + "Built into this Mac.", + detail: "The Ethernet port is part of the Mac itself.", + why: "\(why) Built-in ports run at their full rated speed.", + action: "Nothing to do.", + confidence: .documented + ) + default: + nil } }, @@ -414,11 +440,19 @@ let ethernetValueRules: [ValueRule] = [ return .info( "Reported as \(ethernetSpeedDescription(megabits: megabits)), a nominal figure for the link to \(device).", detail: "Actual speed depends on the USB connection and on the device itself.", + why: "The figure doesn't describe a real network cable.", + action: "Nothing to do.", confidence: .likely(reasons: appleDeviceLinkReasons(context, device: device)) ) } - return .info("Supports Ethernet speeds up to \(ethernetSpeedDescription(megabits: megabits)).", confidence: .documented) + return .info( + "Supports Ethernet speeds up to \(ethernetSpeedDescription(megabits: megabits)).", + detail: "This is the fastest speed the adapter can use. The actual speed depends on the cable and the router or switch.", + why: "Your network connection can't be faster than the slowest part of the link.", + action: "Nothing to do. For full speed, use a cable and switch rated for it.", + confidence: .documented + ) }, ValueRule(.ethernet, field: "spethernet_usb_device_speed") { context in @@ -487,6 +521,7 @@ func usbLinkExplanation(_ value: String, adapterMegabits: Int?) -> ValueExplanat return .info( summary, detail: "The adapter supports \(ethernetSpeedDescription(megabits: adapterMegabits)), but its USB connection runs at up to \(ethernetSpeedDescription(megabits: link.megabits)), which can limit its speed.", + why: "The network can't be faster than the USB link the adapter uses.", action: "For full speed, connect the adapter to a faster USB or Thunderbolt port, not through a slower hub or cable.", confidence: .likely(reasons: [ "The USB connection's reported speed is lower than the adapter's Ethernet speed." @@ -494,7 +529,13 @@ func usbLinkExplanation(_ value: String, adapterMegabits: Int?) -> ValueExplanat ) } - return .info(summary, confidence: .documented) + return .info( + summary, + detail: "This is the USB speed the device negotiated with the Mac.", + why: "A device can't send data faster than its USB link allows.", + action: "Nothing to do.", + confidence: .documented + ) } // MARK: - Wi-Fi @@ -1002,21 +1043,51 @@ private func wifiCapabilityExplanation(_ value: String, feature: String, why: St // MARK: - Bluetooth +// Sources: attrib_on and attrib_off are seen in docs/value-inventory.md. Discoverability +// is described in https://support.apple.com/guide/mac-help/blth1004. + let bluetoothValueRules: [ValueRule] = [ ValueRule(.bluetooth, field: "controller_state") { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .normal("Bluetooth is on.") - case false?: .info("Bluetooth is off, so wireless keyboards, mice, and headphones can't connect.") - case nil: nil + case true?: + .normal( + "Bluetooth is on.", + detail: "The Bluetooth radio was on when the scan ran.", + why: "Wireless keyboards, mice, headphones, and Continuity features such as Handoff and AirDrop can work.", + action: "Nothing to do.", + confidence: .documented + ) + case false?: + .info( + "Bluetooth is off, so wireless keyboards, mice, and headphones can't connect.", + detail: "The Bluetooth radio was off when the scan ran.", + why: "Continuity features such as Handoff, AirDrop, and Unlock with Apple Watch also need Bluetooth.", + action: "If you expected Bluetooth to be on, turn it on in Control Center or System Settings › Bluetooth.", + confidence: .documented + ) + case nil: + nil } }, ValueRule(.bluetooth, field: "controller_discoverable") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .info("This Mac is visible to nearby Bluetooth devices. That normally happens only while Bluetooth settings is open.") + .info( + "This Mac is visible to nearby Bluetooth devices. That normally happens only while Bluetooth settings is open.", + detail: "Nearby devices can find this Mac by name to pair with it.", + why: "Being visible is needed for pairing, but it also shows the Mac's name to people nearby.", + action: "Nothing to do. It stops when you close Bluetooth settings.", + confidence: .documented + ) case false?: - .normal("This Mac isn't visible to nearby devices, the normal state outside Bluetooth settings.") + .normal( + "This Mac isn't visible to nearby devices, the normal state outside Bluetooth settings.", + detail: "Devices that are already paired can still connect.", + why: "Staying hidden keeps the Mac's name private and avoids unwanted pairing requests.", + action: "Nothing to do. To pair a new device, open System Settings › Bluetooth.", + confidence: .documented + ) case nil: nil } @@ -1035,7 +1106,10 @@ let bluetoothValueRules: [ValueRule] = [ let quality: String = rssi >= -60 ? "Strong" : rssi >= -80 ? "Fair" : "Weak" return .info( "\(quality) signal (\(rssi) dBm) when the device was last seen.", - detail: "Values closer to 0 are stronger. Walls, bodies, and distance weaken Bluetooth quickly." + detail: "Values closer to 0 are stronger. Walls, bodies, and distance weaken Bluetooth quickly.", + why: "A weak signal can make audio skip or a keyboard or mouse lag.", + action: "Nothing to do. If the device drops out, move it closer to the Mac.", + confidence: .observed ) } } diff --git a/SystemProfilerExplorerTests/SecondWaveValueTests.swift b/SystemProfilerExplorerTests/SecondWaveValueTests.swift index 71f1fca..5c201cf 100644 --- a/SystemProfilerExplorerTests/SecondWaveValueTests.swift +++ b/SystemProfilerExplorerTests/SecondWaveValueTests.swift @@ -34,7 +34,7 @@ struct SecondWaveValueTests { #expect(bus.summary.contains("not a physical Ethernet adapter")) #expect(bus.confidence?.reasons.count == 2) - #expect(usb.suggestedAction == nil) + #expect(usb.suggestedAction?.contains("faster") != true) } @Test diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index cf56ec8..15e2297 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -51,6 +51,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples + networkValueSamples + softwareHistoryAndFirewallValueSamples + wifiValueSamples + powerValueSamples + storageValueSamples + startupAndOverviewValueSamples + + hardwareValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -368,6 +369,74 @@ private let startupAndOverviewValueSamples: [ValueSample] = { return samples }() +private let hardwareValueSamples: [ValueSample] = { + let display: [String] = ["spdisplays_ndrvs", "[]"] + let port: [String] = ["_items", "[]", "receptacle_1_tag"] + var samples: [ValueSample] = [ + "LCD", "CRT", "retinaLCD", "built-in_retinaLCD", "projector", "television", "airplaydisplay", "built-in-liquid-retina-xdr" + ].map { ValueSample(.displays, display + ["spdisplays_display_type"], "spdisplays_\($0)") } + + samples += ["internal", "external", "airplay"].map { + ValueSample(.displays, display + ["spdisplays_connection_type"], "spdisplays_\($0)") + } + for field in ["spdisplays_online", "spdisplays_main", "spdisplays_mirror", "spdisplays_ambient_brightness"] { + samples += ["spdisplays_yes", "spdisplays_off"].map { ValueSample(.displays, display + [field], $0) } + } + samples += ["gpu", "egpu"].map { ValueSample(.displays, ["sppci_device_type"], "spdisplays_\($0)") } + samples += ["builtin", "pcie_device", "tb_device", "agp_device"].map { ValueSample(.displays, ["sppci_bus"], "spdisplays_\($0)") } + samples += ["spdisplays_metal4", "spdisplays_mtlgpufamilymac2"].map { ValueSample(.displays, ["spdisplays_mtlgpufamilysupport"], $0) } + samples += ["sppci_vendor_Apple", "sppci_vendor_amd"].map { ValueSample(.displays, ["spdisplays_vendor"], $0) } + samples.append(ValueSample(.displays, display + ["spdisplays_pixelresolution"], "spdisplays_3024x1964Retina")) + samples.append(ValueSample(.displays, display + ["_spdisplays_resolution"], "1512 x 982 @ 120.00Hz", siblings: ["_spdisplays_pixels": .string("3024 x 1964")])) + samples.append(ValueSample(.displays, ["sppci_cores"], "40")) + + samples += [ + "airplay", "avb", "bluetooth", "builtin", "displayport", "firewire", "hdmi", "network", "other", "pci", + "thunderbolt", "unknown", "usb", "virtual", "wireless", "bluetoothle", "aggregate" + ].map { ValueSample(.audio, ["_items", "[]", "coreaudio_device_transport"], "coreaudio_device_type_\($0)") } + for field in ["coreaudio_default_audio_input_device", "coreaudio_default_audio_output_device", "coreaudio_default_audio_system_device"] { + samples.append(ValueSample(.audio, ["_items", "[]", field], "spaudio_yes")) + } + samples.append(ValueSample(.audio, ["_items", "[]", "_properties"], "coreaudio_default_audio_system_device")) + samples.append(ValueSample(.audio, ["_items", "[]", "coreaudio_device_srate"], scalar: .integer(48_000))) + + samples += ["receptacle_no_devices_connected", "receptacle_connected"].map { + ValueSample(.thunderbolt, port + ["receptacle_status_key"], $0) + } + samples += ["trained", "training", "untrained", "disabled", "off", "Loopback", "unknown"].map { + ValueSample(.thunderbolt, port + ["link_status_key"], "\($0)_link_status") + } + samples += ["Up to 40 Gb/s", "Up to 20 Gb/s x2", "Up to 10 Gb/s x1", "Up to 120 Gb/s"].map { + ValueSample(.thunderbolt, port + ["current_speed_key"], $0) + } + + samples += ["attrib_on", "attrib_off"].map { ValueSample(.bluetooth, ["controller_properties", "controller_state"], $0) } + samples += ["attrib_on", "attrib_off"].map { ValueSample(.bluetooth, ["controller_properties", "controller_discoverable"], $0) } + samples += ["PCIe", "USB", "UART"].map { ValueSample(.bluetooth, ["controller_properties", "controller_transport"], $0) } + samples.append(ValueSample(.bluetooth, ["device_connected", "[]", "Keyboard", "device_rssi"], "-58")) + + samples.append(ValueSample(.usb, ["_items", "[]", "USBKeyHardwareType"], "Built-in")) + samples += ["LPDDR5", "DDR4"].map { ValueSample(.memory, ["dimm_type"], $0) } + samples += ["ok", "empty", "mapped_out", "unknown"].map { ValueSample(.memory, ["_items", "[]", "dimm_status"], $0) } + samples += ["ecc_enabled", "ecc_disabled"].map { ValueSample(.memory, ["global_ecc_state"], $0) } + samples += ["Yes", "No"].map { ValueSample(.memory, ["is_memory_upgradeable"], $0) } + samples += ["spcardreader_link-speed", "spcardreader_link-width"].map { ValueSample(.cardReader, [$0], "Off") } + + let adapter: [String: ProfileValue] = [ + "spethernet_product_name": .string("USB 10/100/1000 LAN"), + "spethernet_max_link_speed": .string("ethernet_speed_1000") + ] + samples += ["spethernet_usb_device", "spethernet_pcie", "spethernet_builtin"].map { + ValueSample(.ethernet, ["spethernet_bus"], $0, siblings: adapter) + } + samples.append(ValueSample(.ethernet, ["spethernet_max_link_speed"], "ethernet_speed_1000", siblings: adapter)) + samples += ["high_speed", "super_speed", "super_speed_plus"].map { + ValueSample(.ethernet, ["spethernet_usb_device_speed"], $0, siblings: adapter) + } + + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { @@ -554,6 +623,25 @@ struct ValueCatalogTests { #expect(valueExplanation(dataType: .software, path: ["boot_mode"], scalar: .string("network_boot"))?.status == .unknown) } + // MARK: - Hardware + + @Test + func thunderboltLinkStatesFromAppleAreRecognized() throws { + let path: [String] = ["_items", "[]", "receptacle_1_tag", "link_status_key"] + let trained = try #require(valueExplanation(dataType: .thunderbolt, path: path, scalar: .string("trained_link_status"))) + + #expect(trained.status == .normal) + #expect(valueExplanation(dataType: .thunderbolt, path: path, scalar: .string("0x2"))?.status == .unknown) + } + + @Test + func olderAppleGPUBusSpellingIsRecognized() { + #expect(valueExplanation(dataType: .displays, path: ["sppci_bus"], scalar: .string("spdisplays_pcie_device"))?.summary + == "Connected over PCI Express, as a separate graphics card.") + #expect(valueExplanation(dataType: .displays, path: ["spdisplays_display_type"], scalar: .string("spdisplays_retinaLCD"))?.summary + == "Display type: Retina LCD.") + } + @Test func processorFamilyComesFromTheHardwareOverview() { let appleSilicon: [ProfileValue] = [.object(["_name": .string("hardware_overview"), "chip_type": .string("Apple M1")])] diff --git a/docs/value-explanations.md b/docs/value-explanations.md index b2dd6ff..2478874 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -331,3 +331,51 @@ settings on the startup disk of a Mac with Apple silicon" | `activation_lock_status` | `activation_lock_enabled`, `activation_lock_disabled` | Normal, Info | Apple | seen, Apple key | | `number_processors` | `proc total:…:performance:efficiency` | Info | Inferred | seen | | `physical_memory` | a size on Apple silicon | Info | Apple | seen (withheld) | + +## Displays, audio, Bluetooth, Thunderbolt, USB, memory, card readers, and Ethernet + +Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's +`SPDisplaysReporter`, `SPAudioReporter`, `SPThunderboltReporter`, +`SPMemoryReporter`, and `SPEthernetReporter` strings. The glossaries have no +Bluetooth or USB reporter, so those spellings come only from the inventory. +Bluetooth visibility: . + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `spdisplays_display_type` | `spdisplays_built-in-liquid-retina-xdr` | Info | Standard | seen | +| `spdisplays_display_type` | `LCD`, `CRT`, `retinaLCD`, `built-in_retinaLCD`, `projector`, `television`, `airplaydisplay` | Info | Apple | Apple key | +| `spdisplays_display_type` | other `spdisplays_…` names (shown as written) | Info | Standard | as reported | +| `spdisplays_connection_type` | `spdisplays_internal` | Info | Apple | seen | +| `spdisplays_connection_type` | `spdisplays_airplay` | Info | Apple | Apple key | +| `spdisplays_connection_type` | `spdisplays_external` | Info | Apple | unconfirmed | +| `spdisplays_online`, `_main`, `_mirror`, `_ambient_brightness` | `spdisplays_yes`/`_on`, `spdisplays_no`/`_off` | Normal, Info | Apple | seen (`yes`, `off`), Apple key | +| `sppci_device_type` | `spdisplays_gpu` | Info | Apple | seen | +| `sppci_device_type` | `spdisplays_egpu` | Info | Apple | Apple key | +| `sppci_bus` | `spdisplays_builtin` | Info | Apple | seen | +| `sppci_bus` | `spdisplays_pcie_device`, `spdisplays_tb_device`, `spdisplays_agp_device` | Info | Apple | Apple key | +| `sppci_bus` | `spdisplays_pcie` | Info | Apple | unconfirmed | +| `spdisplays_mtlgpufamilysupport` | `spdisplays_metalN` | Info | Apple | seen (`metal4`) | +| `spdisplays_mtlgpufamilysupport` | `spdisplays_mtlgpufamilymacN`, `…commonN` | Info | Apple | Apple key | +| `spdisplays_vendor` | `sppci_vendor_…` | Info | Apple | seen (`Apple`), Apple key (`Nvidia`, `amd`) | +| `coreaudio_device_transport` | `coreaudio_device_type_builtin` | Info | Apple | seen | +| `coreaudio_device_transport` | `airplay`, `avb`, `bluetooth`, `displayport`, `firewire`, `hdmi`, `network`, `other`, `pci`, `thunderbolt`, `unknown`, `usb`, `virtual`, `wireless` | Info | Apple | Apple key | +| `coreaudio_device_transport` | `bluetoothle`, `aggregate` | Info | Standard | unconfirmed | +| `coreaudio_default_audio_*_device`, `_properties` | `spaudio_yes`, `coreaudio_default_audio_*_device` | Info | Apple | seen | +| `receptacle_status_key` | `receptacle_no_devices_connected` | Info | Apple | seen | +| `receptacle_status_key` | `receptacle_connected` | Info | Apple | Apple key | +| `link_status_key` | `trained`, `training`, `untrained`, `disabled`, `off`, `Loopback`, `unknown` + `_link_status` | Normal, Info | Apple | Apple key; current format unconfirmed (withheld in the inventory) | +| `current_speed_key` | `Up to 40 Gb/s` | Info | Apple | seen | +| `current_speed_key` | `Up to 10/20 Gb/s x1/x2` | Info | Apple | Apple key | +| `current_speed_key` | `Up to 80/120 Gb/s` (Thunderbolt 5) | Info | Apple | unconfirmed | +| `controller_state`, `controller_discoverable` | `attrib_on`, `attrib_off` | Normal, Info | Apple | seen | +| `controller_transport` | `PCIe` | Info | Apple | seen | +| `controller_transport` | `USB`, `UART` | Info | Apple | unconfirmed | +| `USBKeyHardwareType` | `Built-in` | Info | Apple | seen | +| `dimm_type` | `LPDDR…`, `DDR…` | Info | Apple | seen (`LPDDR5`) | +| `dimm_status` | `ok`, `empty`, `mapped_out`, `unknown` | Normal, Info, Worth a look | Apple | Apple key | +| `global_ecc_state` | `ecc_enabled`, `ecc_disabled` | Normal, Info | Apple | Apple key | +| `is_memory_upgradeable` | `Yes`, `No` | Info | Apple | Apple key (field) | +| `spcardreader_link-speed`, `-width` | `Off` | Info | Inferred | seen | +| `spethernet_bus` | `spethernet_usb_device`, `spethernet_pcie`, `spethernet_builtin` | Info | Apple | seen (`usb_device`); others unconfirmed | +| `spethernet_max_link_speed` | `ethernet_speed_N` | Info | Apple | seen | +| `spethernet_usb_device_speed` | `low_speed` … `super_speed_plus_by_2` | Info | Apple | seen (`high_speed`); others unconfirmed | From e1187677cbb5a9771342c77dcef43dbe53c10277 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:14:15 +0000 Subject: [PATCH 13/15] Explain every accessibility, region, profile, printer, and Secure Element value Adds Apple's Window zoom style, right-to-left text, male voice, temperature units, and calendar names, plus Printer Sharing and scanner states. Accessibility features that are off are now explained as the usual state instead of showing nothing. --- PLAN.md | 2 +- .../Values/DisplayAndMediaValueRules.swift | 30 +- .../SettingsAndSoftwareValueRules.swift | 428 ++++++++++++++++-- .../Values/SoftwareArtifactValueRules.swift | 24 +- .../SecondWaveValueTests.swift | 5 +- .../ValueCatalogTests.swift | 50 +- docs/value-explanations.md | 40 ++ 7 files changed, 523 insertions(+), 56 deletions(-) diff --git a/PLAN.md b/PLAN.md index 5a9e475..9999ace 100644 --- a/PLAN.md +++ b/PLAN.md @@ -96,7 +96,7 @@ test run. its protections, SIP, secure virtual memory, boot mode, Activation Lock. 12. [x] Displays, audio, Bluetooth, Thunderbolt, USB, memory, card readers, and Ethernet. -13. [ ] Settings and profiles: accessibility, language and region, +13. [x] Settings and profiles: accessibility, language and region, configuration profiles, managed preferences, printers, sync services, Secure Element. 14. [ ] Update `docs/value-inventory.md` to point at the new list, and diff --git a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift index 7f3fbd5..a940d28 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/DisplayAndMediaValueRules.swift @@ -767,14 +767,36 @@ let hardwareStateValueRules: [ValueRule] = [ cardReaderLinkExplanation(context.reportedValue) }, + // Managed preference states: always is seen in docs/value-inventory.md; often and + // once are the other management frequencies Apple's managed preferences use. ValueRule(.managedClient, field: "data_state") { context in - switch context.reportedValue.lowercased() { + let why: String = "It decides whether you can change this setting yourself." + + return switch context.reportedValue.lowercased() { case "always": - .info("Enforced: the setting is always applied, and users can't change it.", confidence: .documented) + .info( + "Enforced: the setting is always applied, and users can't change it.", + detail: "An organization or administrator manages this setting.", + why: why, + action: "Nothing to do on a managed Mac. To change it, contact whoever manages this Mac.", + confidence: .documented + ) case "often": - .info("Applied again each time someone logs in, but users can change it in between.", confidence: .documented) + .info( + "Applied again each time someone logs in, but users can change it in between.", + detail: "The managed value is restored at every login.", + why: why, + action: "Nothing to do. Changes you make last only until you log out.", + confidence: .documented + ) case "once": - .info("Applied once as a starting point; users can change it afterward.", confidence: .documented) + .info( + "Applied once as a starting point; users can change it afterward.", + detail: "The managed value was set once, and later changes are kept.", + why: why, + action: "Nothing to do.", + confidence: .documented + ) default: nil } diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift index c6e9019..4d44940 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift @@ -40,11 +40,15 @@ let syncServicesValueRules: [ValueRule] = [ // The description names which log the entry holds, such as system_log_description. ValueRule(.syncServices, field: "description") { context in let value: String = context.reportedValue + let why: String = "It can help diagnose problems with syncing contacts, calendars, and other data." + let action: String = "Nothing to do unless something isn't syncing. Then the log can help Apple Support or your IT team." if value == "system_log_description" { return .info( "This entry is the macOS system log (system.log), included because synchronization problems can leave messages there.", detail: "Since macOS Sierra, most messages go to the unified log instead, so this file is often short. It's a retained excerpt, not a complete record of sync activity.", + why: why, + action: action, confidence: .likely(reasons: [ "The value's name says it describes the system log.", "The entry's contents field holds system.log text." @@ -59,6 +63,8 @@ let syncServicesValueRules: [ValueRule] = [ return .info( "This entry holds the \(friendlyReportGroupName(value)).", detail: "It's a retained excerpt, not a complete record of sync activity.", + why: why, + action: action, confidence: .likely(reasons: [ "The value's name ends in “log description”, the pattern System Information uses to name the log an entry holds." ]) @@ -68,13 +74,21 @@ let syncServicesValueRules: [ValueRule] = [ // MARK: - Language and region +// Sources: text_direction_ltr, value_no, and US are seen in docs/value-inventory.md. The +// keys text_direction_ltr and _rtl, value_yes and _no, voice_gender_female and _male, +// Celsius and Fahrenheit, and the calendar identifiers (gregorian, buddhist, chinese, +// coptic, ethiopic, ethiopic-amete-alem, hebrew, indian, islamic, islamic-civil, +// islamic-tbla, islamic-umalqura, iso8601, japanese, persian, roc) are in Apple's +// SPInternationalReporter strings. The inventory withheld this Mac's user_calendar, +// user_temperature_unit, and voice gender values. + let internationalValueRules: [ValueRule] = [ ValueRule(.international, field: "system_text_direction") { context in - switch tokenSuffix(context.reportedValue, after: "text_direction_") { - case "ltr": .info("Text reads left to right.") - case "rtl": .info("Text reads right to left.") - default: nil - } + textDirectionExplanation(context.reportedValue) + }, + + ValueRule(.international, field: "user_text_direction") { context in + textDirectionExplanation(context.reportedValue) }, ValueRule(.international, field: "system_uses_metric_system") { context in @@ -91,59 +105,228 @@ let internationalValueRules: [ValueRule] = [ return nil } - return .info("Region: \(region).") + return .info( + "Region: \(region).", + detail: "This is the region macOS uses for formats and region-specific features.", + why: "It sets the default date, time, and number formats and which services are offered.", + action: "Nothing to do. You can change it in System Settings › General › Language & Region.", + confidence: .documented + ) }, ValueRule(.international, field: "user_assistant_voice_gender") { context in - tokenSuffix(context.reportedValue, after: "voice_gender_").map { - .info("The assistant's voice is set to a \($0.replacingOccurrences(of: "_", with: " ")) voice.") + tokenSuffix(context.reportedValue, after: "voice_gender_").map { gender in + .info( + "The assistant's voice is set to a \(gender.replacingOccurrences(of: "_", with: " ")) voice.", + detail: "This is the voice Siri speaks with.", + why: "It only changes how Siri sounds.", + action: "Nothing to do. You can change it in System Settings › Siri.", + confidence: .documented + ) + } + }, + + ValueRule(.international, field: "user_temperature_unit") { context in + switch context.reportedValue.lowercased() { + case "celsius", "fahrenheit": + .info( + "Temperatures are shown in \(context.reportedValue.prefix(1).uppercased() + context.reportedValue.dropFirst().lowercased()).", + detail: "Weather and other apps show temperatures in this unit.", + why: "It only changes how temperatures are displayed.", + action: "Nothing to do. You can change it in System Settings › General › Language & Region.", + confidence: .documented + ) + default: + nil } + }, + + ValueRule(.international, field: "user_calendar") { context in + calendarExplanation(context.reportedValue) } ] +private func textDirectionExplanation(_ value: String) -> ValueExplanation? { + switch tokenSuffix(value, after: "text_direction_") { + case "ltr": + .info( + "Text reads left to right.", + detail: "The language in use is written from left to right, as English is.", + why: "It sets the direction of text and the layout of windows and menus.", + action: "Nothing to do.", + confidence: .documented + ) + case "rtl": + .info( + "Text reads right to left.", + detail: "The language in use is written from right to left, as Arabic and Hebrew are.", + why: "Windows and menus are laid out mirrored to match.", + action: "Nothing to do.", + confidence: .documented + ) + default: + nil + } +} + private func metricSystemExplanation(_ value: String) -> ValueExplanation? { switch decodeBooleanLike(value) { - case true?: .info("Measurements use the metric system.") - case false?: .info("Measurements don't use the metric system (for example, inches and pounds).") - case nil: nil + case true?: + .info( + "Measurements use the metric system.", + detail: "Apps show lengths, weights, and volumes in metric units such as centimeters and kilograms.", + why: "It only changes how measurements are displayed.", + action: "Nothing to do. You can change it in System Settings › General › Language & Region.", + confidence: .documented + ) + case false?: + .info( + "Measurements don't use the metric system (for example, inches and pounds).", + detail: "Apps show lengths, weights, and volumes in US or imperial units.", + why: "It only changes how measurements are displayed.", + action: "Nothing to do. You can change it in System Settings › General › Language & Region.", + confidence: .documented + ) + case nil: + nil + } +} + +private func calendarExplanation(_ value: String) -> ValueExplanation? { + let names: [String: String] = [ + "gregorian": "Gregorian", + "buddhist": "Buddhist", + "chinese": "Chinese", + "coptic": "Coptic", + "ethiopic": "Ethiopic", + "ethiopic-amete-alem": "Ethiopic (Amete Alem)", + "hebrew": "Hebrew", + "indian": "Indian National", + "islamic": "Islamic (Astronomical)", + "islamic-civil": "Islamic (Tabular, Friday origin)", + "islamic-tbla": "Islamic (Tabular, Thursday origin)", + "islamic-umalqura": "Islamic (Umm al-Qura)", + "iso8601": "ISO 8601", + "japanese": "Japanese", + "persian": "Persian", + "roc": "Minguo (Republic of China)" + ] + + guard let name = names[value.lowercased()] else { + return nil } + + return .info( + "Dates use the \(name) calendar.", + detail: value.lowercased() == "gregorian" + ? "The Gregorian calendar is the one most countries use." + : "Dates across macOS are shown in this calendar system instead of the Gregorian calendar.", + why: "It changes how dates and years are shown in apps, not the dates themselves.", + action: "Nothing to do. You can change it in System Settings › General › Language & Region.", + confidence: .documented + ) } // MARK: - Accessibility +// Sources: black_on_white, zoom_full_screen, and off are seen in +// docs/value-inventory.md. black_on_white, white_on_black, zoom_full_screen, +// zoom_split_screen, zoom_in_window, on, and off are keys in Apple's +// SPUniversalAccessReporter strings. zoom_picture_in_picture and zoom_pip are +// unconfirmed. The inventory withheld this Mac's contrast value. + let accessibilityValueRules: [ValueRule] = [ ValueRule(.universalAccess, field: "display") { context in switch context.reportedValue { - case "black_on_white": .info("Normal colors: dark text on a light background.") - case "white_on_black": .info("Colors are inverted: light text on a dark background.") - default: nil + case "black_on_white": + .info( + "Normal colors: dark text on a light background.", + detail: "Invert Colors is off.", + why: "The screen shows colors as apps intend.", + action: "Nothing to do.", + confidence: .documented + ) + case "white_on_black": + .info( + "Colors are inverted: light text on a dark background.", + detail: "Invert Colors is on in Accessibility › Display.", + why: "It can make text easier to read, but photos and videos look inverted too.", + action: "Nothing to do if you use it. You can turn it off in System Settings › Accessibility › Display.", + confidence: .documented + ) + default: + nil } }, ValueRule(.universalAccess, field: "zoomMode") { context in - switch tokenSuffix(context.reportedValue, after: "zoom_") { - case "full_screen": .info("When Zoom is on, it magnifies the whole screen.") - case "picture_in_picture", "pip": .info("When Zoom is on, it magnifies a separate window that follows the pointer.") - case "split_screen": .info("When Zoom is on, it magnifies part of the screen in a separate area.") - default: nil + let action: String = "Nothing to do. You can change the zoom style in System Settings › Accessibility › Zoom." + let why: String = "It only matters when Zoom is on." + + return switch tokenSuffix(context.reportedValue, after: "zoom_") { + case "full_screen": + .info( + "When Zoom is on, it magnifies the whole screen.", + detail: "The zoom style is Full Screen.", + why: why, + action: action, + confidence: .documented + ) + case "in_window", "picture_in_picture", "pip": + .info( + "When Zoom is on, it magnifies a separate window that follows the pointer.", + detail: "The zoom style is Picture-in-Picture (called Window in older macOS).", + why: why, + action: action, + confidence: .documented + ) + case "split_screen": + .info( + "When Zoom is on, it magnifies part of the screen in a separate area.", + detail: "The zoom style is Split Screen.", + why: why, + action: action, + confidence: .documented + ) + default: + nil } } ] + accessibilityFeatureRules([ - ("voiceover", "VoiceOver, the built-in screen reader, is on."), - ("sticky_keys", "Sticky Keys is on: modifier keys stay active after you press them."), - ("slow_keys", "Slow Keys is on: a key must be held briefly before it registers."), - ("mouse_keys", "Mouse Keys is on: the keyboard can move the pointer."), - ("cursor_mag", "The pointer is enlarged."), - ("flash_screen", "The screen flashes when an alert sound plays."), - ("keyboardZoom", "Zoom can be turned on with keyboard shortcuts."), - ("scrollZoom", "Zoom can be controlled by scrolling with a modifier key.") + ("voiceover", "VoiceOver, the built-in screen reader, is on.", "VoiceOver is off."), + ("sticky_keys", "Sticky Keys is on: modifier keys stay active after you press them.", "Sticky Keys is off."), + ("slow_keys", "Slow Keys is on: a key must be held briefly before it registers.", "Slow Keys is off."), + ("mouse_keys", "Mouse Keys is on: the keyboard can move the pointer.", "Mouse Keys is off."), + ("cursor_mag", "The pointer is enlarged.", "The pointer is its normal size."), + ("flash_screen", "The screen flashes when an alert sound plays.", "The screen doesn't flash for alerts."), + ("keyboardZoom", "Zoom can be turned on with keyboard shortcuts.", "Zoom's keyboard shortcuts are off."), + ("scrollZoom", "Zoom can be controlled by scrolling with a modifier key.", "Zooming by scrolling with a modifier key is off.") ]) -/// Explains accessibility features only when they're on; "off" is the usual state. -private func accessibilityFeatureRules(_ features: [(field: String, whenOn: String)]) -> [ValueRule] { +/// On is Info, because it changes how the Mac behaves; off is the usual state. +private func accessibilityFeatureRules(_ features: [(field: String, whenOn: String, whenOff: String)]) -> [ValueRule] { features.map { feature in - ValueRule(.universalAccess, field: feature.field, unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true ? .info(feature.whenOn, confidence: .documented) : nil + ValueRule(.universalAccess, field: feature.field) { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + feature.whenOn, + detail: "This accessibility feature was on when the scan ran.", + why: "It changes how the Mac looks, sounds, or responds to input for everyone who uses it.", + action: "Nothing to do if someone relies on it. You can change it in System Settings › Accessibility.", + confidence: .documented + ) + case false?: + .normal( + feature.whenOff, + detail: "This accessibility feature was off when the scan ran, which is the default.", + why: "The Mac behaves as usual.", + action: "Nothing to do. You can turn it on in System Settings › Accessibility if it would help.", + confidence: .documented + ) + case nil: + nil + } } } } @@ -267,21 +450,39 @@ private func partitionContentExplanation(_ value: String) -> ValueExplanation? { // MARK: - Configuration profiles +// Sources: unsigned, Manual, and no are seen in docs/value-inventory.md. Apple's +// glossaries have no strings for this section, so verified, invalid, unverified, and +// the MDM install sources are unconfirmed. Profiles are described in +// https://support.apple.com/guide/mac-help/mh35561. + +private let reviewProfiles: String = "Keep only profiles you recognize. You can review them in System Settings › General › Device Management." + let configurationProfileValueRules: [ValueRule] = [ ValueRule(.configurationProfiles, field: "spconfigprofile_verification_state") { context in - switch context.reportedValue.lowercased() { + let why: String = "A profile can change security, network, and privacy settings, so it matters who published it." + + return switch context.reportedValue.lowercased() { case "verified": - .normal("The profile's signature is verified, so its publisher can be confirmed.", confidence: .documented) + .normal( + "The profile's signature is verified, so its publisher can be confirmed.", + detail: "The profile was signed, and macOS checked the signature against a trusted certificate.", + why: why, + action: "Nothing to do if you recognize the publisher.", + confidence: .documented + ) case "unsigned": .info( "The profile isn't signed, so its publisher can't be confirmed.", detail: "Unsigned profiles are common for ones you create or install yourself. A profile can change security and privacy settings, so it matters that you know where it came from.", + why: why, action: "Keep only profiles you recognize. You can review them by searching for Profiles in System Settings.", confidence: .documented ) case "invalid", "unverified": .review( "The profile's signature couldn't be verified.", + detail: "The profile is signed, but macOS couldn't confirm the signature, for example because the certificate expired or isn't trusted.", + why: why, action: "Remove the profile unless you know where it came from.", confidence: .documented ) @@ -294,11 +495,23 @@ let configurationProfileValueRules: [ValueRule] = [ let value: String = context.reportedValue.lowercased() if value == "manual" { - return .info("Installed by hand, not by an organization's device management.") + return .info( + "Installed by hand, not by an organization's device management.", + detail: "Someone opened the profile file and approved it in System Settings.", + why: "Profiles installed by hand are easy to forget, but they keep changing settings until removed.", + action: reviewProfiles, + confidence: .documented + ) } if value.contains("mdm") || value.contains("management") { - return .info("Installed by an organization's device management (MDM).") + return .info( + "Installed by an organization's device management (MDM).", + detail: "A management server sent this profile to the Mac.", + why: "The organization that manages this Mac controls these settings.", + action: "Nothing to do on a work or school Mac. Otherwise, check System Settings › General › Device Management.", + confidence: .documented + ) } return nil @@ -306,32 +519,155 @@ let configurationProfileValueRules: [ValueRule] = [ ValueRule(.configurationProfiles, field: "spconfigprofile_RemovalDisallowed", unrecognizedValues: .ignore) { context in switch decodeBooleanLike(context.reportedValue) { - case true?: .info("This profile can't be removed without the organization that installed it.", confidence: .documented) - case false?: .info("This profile can be removed.") - case nil: nil + case true?: + .info( + "This profile can't be removed without the organization that installed it.", + detail: "The profile is locked, usually by device management.", + why: "Its settings stay in place until the organization removes it.", + action: "Nothing to do on a managed Mac. If you don't know the organization, contact whoever set up the Mac.", + confidence: .documented + ) + case false?: + .info( + "This profile can be removed.", + detail: "Anyone with an administrator account can remove it.", + why: "You stay in control of the settings it changes.", + action: reviewProfiles, + confidence: .documented + ) + case nil: + nil } } ] // MARK: - Printers +// Sources: the printer state names follow the CUPS printer states (idle, processing, +// stopped). The inventory withheld this Mac's printer values, so every spelling here +// is unconfirmed. + let printerValueRules: [ValueRule] = [ ValueRule(.printers, field: "status") { context in switch context.reportedValue.lowercased() { - case "idle": .info("The printer was idle when the scan ran.") - case "printing", "processing": .info("The printer was printing when the scan ran.") - case "stopped": .info("The printer queue is paused.", action: "Resume it from the printer's queue window if you want to print.") - default: nil + case "idle": + .info( + "The printer was idle when the scan ran.", + detail: "The printer queue was ready and had nothing to print.", + why: "It's ready for new jobs.", + action: "Nothing to do.", + confidence: .observed + ) + case "printing", "processing": + .info( + "The printer was printing when the scan ran.", + detail: "A job was being sent to the printer.", + why: "It's a snapshot of that moment.", + action: "Nothing to do.", + confidence: .observed + ) + case "stopped": + .info( + "The printer queue is paused.", + detail: "macOS isn't sending jobs to this printer, often after an error or because someone paused it.", + why: "New print jobs wait in the queue until it's resumed.", + action: "Resume it from the printer's queue window if you want to print.", + confidence: .observed + ) + default: + nil } }, ValueRule(.printers, field: "shared", unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true - ? .info("This printer is shared with other devices on the network.") - : nil + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "This printer is shared with other devices on the network.", + detail: "Printer Sharing makes it available to other computers.", + why: "Others on the network can print to it, and this Mac must be awake for them to do so.", + action: "Nothing to do if you meant to share it. You can change it in System Settings › General › Sharing.", + confidence: .documented + ) + case false?: + .info( + "This printer isn't shared.", + detail: "Only this Mac prints to it through this queue.", + why: "Other computers can't print through this Mac.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil + } }, ValueRule(.printers, field: "default", unrecognizedValues: .ignore) { context in - decodeBooleanLike(context.reportedValue) == true ? .info("The default printer.") : nil + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "The default printer.", + detail: "Apps choose this printer first in the Print dialog.", + why: "Print jobs go here unless you pick another printer.", + action: "Nothing to do. You can change it in System Settings › Printers & Scanners.", + confidence: .documented + ) + case false?: + .info( + "Not the default printer.", + detail: "You have to choose this printer in the Print dialog.", + why: "Print jobs go to another printer by default.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil + } + }, + + ValueRule(.printers, field: "printersharing", unrecognizedValues: .ignore) { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "Printer Sharing is on.", + detail: "This Mac can share its printers with other computers on the network.", + why: "Other computers can print through this Mac.", + action: "Nothing to do if you meant to share. You can change it in System Settings › General › Sharing.", + confidence: .documented + ) + case false?: + .normal( + "Printer Sharing is off.", + detail: "This Mac doesn't share its printers.", + why: "Other computers can't print through this Mac. This is the default.", + action: "Nothing to do.", + confidence: .documented + ) + case nil: + nil + } + }, + + ValueRule(.printers, field: "scanner", unrecognizedValues: .ignore) { context in + switch decodeBooleanLike(context.reportedValue) { + case true?: + .info( + "This printer can also scan.", + detail: "macOS found a scanner in this device.", + why: "You can scan from Printers & Scanners or Image Capture.", + action: "Nothing to do.", + confidence: .observed + ) + case false?: + .info( + "This printer doesn't scan, or macOS didn't find a scanner in it.", + detail: "No scanner was reported for this device.", + why: "Scanning isn't available through this printer.", + action: "Nothing to do.", + confidence: .observed + ) + case nil: + nil + } } ] diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift index 5cee3d2..a2127eb 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SoftwareArtifactValueRules.swift @@ -443,10 +443,20 @@ let softwareFlagValueRules: [ValueRule] = [ ValueRule(.secureElement, field: "se_in_restricted_mode") { context in switch decodeBooleanLike(context.reportedValue) { case false?: - .normal("The Secure Element isn't in restricted mode.") + .normal( + "The Secure Element isn't in restricted mode.", + detail: "The chip that stores Apple Pay cards reports that it works without restrictions.", + why: "Apple Pay and other features that use it can work normally.", + action: "Nothing to do.", + confidence: .likely(reasons: [ + "Apple doesn't document this field. The Secure Element holds Apple Pay credentials, so limits on it would affect those features." + ]) + ) case true?: .info( "The Secure Element is in restricted mode, so some of its features, such as Apple Pay, may be unavailable.", + detail: "The chip that stores Apple Pay cards reports that it's restricted.", + why: "Apple Pay on this Mac may not work.", action: "If Apple Pay or other secure features don't work, contact Apple Support.", confidence: .likely(reasons: [ "Apple doesn't document this field. The Secure Element holds Apple Pay credentials, so limits on it would affect those features." @@ -460,10 +470,20 @@ let softwareFlagValueRules: [ValueRule] = [ ValueRule(.secureElement, field: "se_prod_signed") { context in switch decodeBooleanLike(context.reportedValue) { case true?: - .normal("The Secure Element runs production-signed software, as Macs sold to customers do.") + .normal( + "The Secure Element runs production-signed software, as Macs sold to customers do.", + detail: "Its software is signed for retail devices.", + why: "Apple Pay can trust it.", + action: "Nothing to do.", + confidence: .likely(reasons: [ + "Apple doesn't document this field. Its name and the other Secure Element fields suggest it reports production versus development signing." + ]) + ) case false?: .info( "The Secure Element's software isn't production-signed, which is expected only on development or prototype hardware.", + detail: "Its software is signed for development use.", + why: "Apple Pay may not work on development hardware.", action: "If this is an ordinary retail Mac, contact Apple Support.", confidence: .likely(reasons: [ "Apple doesn't document this field. Its name and the other Secure Element fields suggest it reports production versus development signing." diff --git a/SystemProfilerExplorerTests/SecondWaveValueTests.swift b/SystemProfilerExplorerTests/SecondWaveValueTests.swift index 5c201cf..4ec554e 100644 --- a/SystemProfilerExplorerTests/SecondWaveValueTests.swift +++ b/SystemProfilerExplorerTests/SecondWaveValueTests.swift @@ -69,9 +69,10 @@ struct SecondWaveValueTests { } @Test - func accessibilityFeaturesAreExplainedOnlyWhenOn() { + func accessibilityFeaturesAreInfoWhenOnAndNormalWhenOff() { #expect(valueExplanation(dataType: .universalAccess, path: ["voiceover"], scalar: .string("on"))?.summary.contains("VoiceOver") == true) - #expect(valueExplanation(dataType: .universalAccess, path: ["voiceover"], scalar: .string("off")) == nil) + #expect(valueExplanation(dataType: .universalAccess, path: ["voiceover"], scalar: .string("on"))?.status == .informational) + #expect(valueExplanation(dataType: .universalAccess, path: ["voiceover"], scalar: .string("off"))?.status == .normal) } @Test diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index 15e2297..aaaac93 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -51,7 +51,7 @@ let intelReport: ValueReportContext = ValueReportContext(usbDeviceNames: nil, pr let explainedValueSamples: [ValueSample] = applicationValueSamples + fontValueSamples + extensionValueSamples + networkValueSamples + softwareHistoryAndFirewallValueSamples + wifiValueSamples + powerValueSamples + storageValueSamples + startupAndOverviewValueSamples - + hardwareValueSamples + + hardwareValueSamples + settingsValueSamples /// Each value is checked with no Hardware section, on Apple silicon, and on an Intel Mac, /// because what an architecture means depends on the Mac. @@ -437,6 +437,54 @@ private let hardwareValueSamples: [ValueSample] = { return samples }() +private let settingsValueSamples: [ValueSample] = { + var samples: [ValueSample] = ["black_on_white", "white_on_black"].map { + ValueSample(.universalAccess, ["display"], $0) + } + + samples += ["zoom_full_screen", "zoom_split_screen", "zoom_in_window", "zoom_picture_in_picture"].map { + ValueSample(.universalAccess, ["zoomMode"], $0) + } + for field in ["voiceover", "sticky_keys", "slow_keys", "mouse_keys", "cursor_mag", "flash_screen", "keyboardZoom", "scrollZoom"] { + samples += ["on", "off"].map { ValueSample(.universalAccess, [field], $0) } + } + + for field in ["system_text_direction", "user_text_direction"] { + samples += ["text_direction_ltr", "text_direction_rtl"].map { ValueSample(.international, [field], $0) } + } + for field in ["system_uses_metric_system", "user_uses_metric_system"] { + samples += ["value_yes", "value_no"].map { ValueSample(.international, [field], $0) } + } + samples.append(ValueSample(.international, ["system_country"], "US")) + samples += ["voice_gender_female", "voice_gender_male"].map { ValueSample(.international, ["user_assistant_voice_gender"], $0) } + samples += ["Celsius", "Fahrenheit"].map { ValueSample(.international, ["user_temperature_unit"], $0) } + samples += [ + "gregorian", "buddhist", "chinese", "coptic", "ethiopic", "ethiopic-amete-alem", "hebrew", "indian", "islamic", + "islamic-civil", "islamic-tbla", "islamic-umalqura", "iso8601", "japanese", "persian", "roc" + ].map { ValueSample(.international, ["user_calendar"], $0) } + + samples += ["verified", "unsigned", "invalid"].map { + ValueSample(.configurationProfiles, ["_items", "[]", "spconfigprofile_verification_state"], $0) + } + samples += ["Manual", "MDM"].map { ValueSample(.configurationProfiles, ["_items", "[]", "spconfigprofile_install_source"], $0) } + samples += ["yes", "no"].map { ValueSample(.configurationProfiles, ["_items", "[]", "spconfigprofile_RemovalDisallowed"], $0) } + samples += ["always", "often", "once"].map { ValueSample(.managedClient, ["_items", "[]", "data_state"], $0) } + + samples += ["idle", "processing", "stopped"].map { ValueSample(.printers, ["_items", "[]", "status"], $0) } + for field in ["shared", "default", "printersharing", "scanner"] { + samples += ["yes", "no"].map { ValueSample(.printers, ["_items", "[]", field], $0) } + } + + samples += ["system_log_description", "sync_diagnostics_log_description"].map { + ValueSample(.syncServices, ["_items", "[]", "description"], $0) + } + for field in ["se_in_restricted_mode", "se_prod_signed"] { + samples += ["Yes", "No"].map { ValueSample(.secureElement, [field], $0) } + } + + return samples +}() + struct ValueCatalogTests { @Test(arguments: explainedValueSamples) func everyKnownValueHasEveryPart(_ sample: ValueSample) throws { diff --git a/docs/value-explanations.md b/docs/value-explanations.md index 2478874..5a56354 100644 --- a/docs/value-explanations.md +++ b/docs/value-explanations.md @@ -379,3 +379,43 @@ Bluetooth visibility: . | `spethernet_bus` | `spethernet_usb_device`, `spethernet_pcie`, `spethernet_builtin` | Info | Apple | seen (`usb_device`); others unconfirmed | | `spethernet_max_link_speed` | `ethernet_speed_N` | Info | Apple | seen | | `spethernet_usb_device_speed` | `low_speed` … `super_speed_plus_by_2` | Info | Apple | seen (`high_speed`); others unconfirmed | + +## Settings and profiles + +Sources: the values seen in `docs/value-inventory.md`, and the keys in Apple's +`SPUniversalAccessReporter`, `SPInternationalReporter`, and +`SPSecureElementReporter` strings. The glossaries have no strings for +configuration profiles, managed preferences, printers, or sync services. +Profiles: "Use configuration profiles to standardize settings on Mac computers" +(). Printer states follow the +CUPS printer states. + +| field | value | status | source | spelling | +|---|---|---|---|---| +| `display` (accessibility) | `black_on_white` | Info | Apple | seen | +| `display` (accessibility) | `white_on_black` | Info | Apple | Apple key | +| `zoomMode` | `zoom_full_screen` | Info | Apple | seen | +| `zoomMode` | `zoom_split_screen`, `zoom_in_window` | Info | Apple | Apple key | +| `zoomMode` | `zoom_picture_in_picture`, `zoom_pip` | Info | Apple | unconfirmed | +| `voiceover`, `sticky_keys`, `slow_keys`, `mouse_keys`, `cursor_mag`, `flash_screen`, `keyboardZoom`, `scrollZoom` | `on` | Info | Apple | Apple key | +| same fields | `off` | Normal | Apple | seen | +| `system_text_direction`, `user_text_direction` | `text_direction_ltr`, `text_direction_rtl` | Info | Apple | seen (`ltr`), Apple key | +| `system_uses_metric_system`, `user_uses_metric_system` | `value_yes`, `value_no` | Info | Apple | seen (`value_no`), Apple key | +| `system_country` | two-letter country codes | Info | Apple | seen (`US`) | +| `user_assistant_voice_gender` | `voice_gender_female`, `voice_gender_male` | Info | Apple | Apple key (withheld in the inventory) | +| `user_temperature_unit` | `Celsius`, `Fahrenheit` | Info | Apple | Apple key | +| `user_calendar` | `gregorian`, `buddhist`, `chinese`, `coptic`, `ethiopic`, `ethiopic-amete-alem`, `hebrew`, `indian`, `islamic`, `islamic-civil`, `islamic-tbla`, `islamic-umalqura`, `iso8601`, `japanese`, `persian`, `roc` | Info | Apple | Apple key (withheld in the inventory) | +| `spconfigprofile_verification_state` | `unsigned` | Info | Apple | seen | +| `spconfigprofile_verification_state` | `verified` | Normal | Apple | unconfirmed | +| `spconfigprofile_verification_state` | `invalid`, `unverified` | Worth a look | Apple | unconfirmed | +| `spconfigprofile_install_source` | `Manual` | Info | Apple | seen | +| `spconfigprofile_install_source` | values containing `MDM` or `management` | Info | Apple | unconfirmed | +| `spconfigprofile_RemovalDisallowed` | `yes`, `no` | Info | Apple | seen (`no`) | +| `data_state` (managed preferences) | `always` | Info | Apple | seen | +| `data_state` (managed preferences) | `often`, `once` | Info | Apple | unconfirmed | +| `status` (printers) | `idle`, `processing`/`printing`, `stopped` | Info | Standard | unconfirmed (withheld in the inventory) | +| `shared`, `default`, `printersharing`, `scanner` (printers) | yes, no | Normal, Info | Apple, Standard | unconfirmed (withheld in the inventory) | +| `description` (sync services) | `system_log_description` | Info | Inferred | seen | +| `description` (sync services) | other `…_log_description` names | Info | Inferred | unconfirmed | +| `se_in_restricted_mode` | `No`, `Yes` | Normal, Info | Inferred | seen (`No`), Apple key | +| `se_prod_signed` | `Yes`, `No` | Normal, Info | Inferred | Apple key (withheld in the inventory) | From 8e2e72f53b03eaa3307fa7e821c9c4ee320b9ce7 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:14:53 +0000 Subject: [PATCH 14/15] List the values that need a scan on a real Mac Points the value inventory at docs/value-explanations.md and lists, by section, the spellings no public source confirms and the fields whose values the inventory withheld. --- PLAN.md | 53 ++++++++++++++++++++++++++++++++++++++--- docs/value-inventory.md | 4 ++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/PLAN.md b/PLAN.md index 9999ace..d88af2f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -99,14 +99,61 @@ test run. 13. [x] Settings and profiles: accessibility, language and region, configuration profiles, managed preferences, printers, sync services, Secure Element. -14. [ ] Update `docs/value-inventory.md` to point at the new list, and +14. [x] Update `docs/value-inventory.md` to point at the new list, and finish the scan list below. ## Values that need a scan on a real Mac -To be completed as the steps land. These are spellings that no public -source confirms, or fields whose values the inventory withheld. +Each of these is explained, but no public source confirms the exact spelling +current macOS reports, or the inventory withheld the field's values. A scan with +the matching hardware or setting would confirm them (or show a spelling to add). +Until then, a different spelling is shown as "not yet explained". + +**Fields whose value format is unknown** (no rule, or the rule may never match): + +- `contrast` (Accessibility): no rule; its values were withheld. +- `ibridge_extra_boot_policies` (Apple Bridge): no rule; its values were withheld. +- `UserVisible` (scheduled power events): no rule; its values were withheld. +- `link_status_key` (Thunderbolt): the rule matches Apple's `trained_link_status` + family, but current macOS may report a number such as `0x2`. +- `printersharing`, `scanner`, `shared`, `default`, and `status` (Printers): + needs a Mac with a printer set up. + +**Spellings to confirm, by section** (full list in `docs/value-explanations.md`): + +- Applications: `arch_ppc`, `ios_app_store`; and whether current macOS still + reports the older Apple keys `arch_i32`, `arch_i32_i64`, `app_store`. +- Extensions: whether current macOS still reports `spext_runtime_environment`, + `spext_obtained_from`, and `spext_notarized`. +- Network: `PPP (PPPoE)`, `PPP (L2TP)`, `PPP (PPTP)`; Ethernet media speeds + other than `100baseTX` and `1000baseT`; `spethernet_pcie`, + `spethernet_builtin`; USB link speeds other than `high_speed` (needs a USB + Ethernet adapter on a faster port). +- Firewall: `spfirewall_globalstate_off` (turn the firewall off and scan). +- Wi-Fi: `spairport_status_disconnected`, `_not_associated`; security modes + `wpa3_enterprise`, `wpa2_wpa3_enterprise`, `owe`, `wpa_personal_mixed`; + locale `MKK` (needs networks of those kinds nearby, or a Mac in Japan). +- Battery: `Normal`, `Service Recommended` and the other System Settings + names (needs a notebook whose battery isn't `Good`). +- Storage: file systems `ExFAT`, `MS-DOS FAT32`, `NTFS`; protocols `USB`, + `Thunderbolt`, `SATA`, `PCI-Express`, `NVMe`, `Secure Digital`; partition + types `EFI`, `Apple_HFS`, `Apple_Boot`, `Apple_CoreStorage`, + `Microsoft Basic Data` (needs external drives formatted each way). +- Startup security: `Reduced Security`, `Permissive Security`; `Disabled` for + the `ibridge_sb_*` protections; `Custom Configuration`; `Yes` for + `ibridge_sb_other_kext` and the MDM fields (needs a Mac with a lowered + security policy). +- Displays and audio: `spdisplays_external`, `spdisplays_pcie`; audio + transports `bluetoothle` and `aggregate`; Thunderbolt 5 speeds. +- Bluetooth: controller transports `USB` and `UART` (older Macs). +- Settings: zoom styles `zoom_picture_in_picture` and `zoom_pip`; profile + states `verified`, `invalid`, `unverified` and MDM install sources; managed + preference states `often` and `once`; other sync log names. ## Blocked or deferred - Regenerating `docs/value-inventory.md` needs a full scan on a Mac. +- Apple Bridge `ibridge_external_boot` (Macs with the T2 Security Chip) has + Apple strings (`External Drive`, `Network`, `Internal`, `Disallowed`, + `BootCamp`), but which field reports which of them isn't clear without a + scan of a T2 Mac, so it has no rule yet. diff --git a/docs/value-inventory.md b/docs/value-inventory.md index 742843c..30f4f2a 100644 --- a/docs/value-inventory.md +++ b/docs/value-inventory.md @@ -16,12 +16,12 @@ Column key: **class** = enumeration, boolean-like, numeric, identifier, or free ## Value explanations added since this inventory -The **value-aware** column and the count above predate value explanations. Each value is now explained by the rules in `SystemProfilerExplorer/Core/Explanations/Values`. The tables below are unchanged, because regenerating them needs a full scan on a Mac. +The **value-aware** column and the count above predate value explanations. Each value is now explained by the rules in `SystemProfilerExplorer/Core/Explanations/Values`, and **`docs/value-explanations.md` lists every explained value** with its status, its source, and whether its spelling is confirmed. The tables below are unchanged, because regenerating them needs a full scan on a Mac. Among the fields this inventory classes as enumeration or boolean-like, these have no value rule yet: - **Values that are identifiers, names, dates, versions, or sizes**, which the class heuristic counted as enumerations. The field explanation covers them, and a rule would keep personal values in anonymized samples. Examples: `machine_model`, `chip_type`, `boot_volume`, locale and language codes, SDK versions, display and card reader IDs, `lastModified`, battery lot codes, Secure Element versions, Thunderbolt route strings and UIDs, and printer names. -- **Enumerations whose values the inventory withheld**, so their format needs a sample from a Mac: `contrast` (Accessibility), `ibridge_extra_boot_policies` (Apple Bridge), `link_status_key` (Thunderbolt), `printersharing` and `scanner` (Printers), and `UserVisible` (scheduled power events). +- **Enumerations whose values the inventory withheld and whose format no public source shows**: `contrast` (Accessibility), `ibridge_extra_boot_policies` (Apple Bridge), and `UserVisible` (scheduled power events). `link_status_key` (Thunderbolt) and `printersharing` and `scanner` (Printers) now have rules for the spellings Apple's strings or the usual yes/no forms use, but the format this Mac reports still needs a sample; an unmatched value is shown as not yet explained. - **Audio device names and manufacturers** (`coreaudio_input_source`, `coreaudio_output_source`, `coreaudio_device_manufacturer`), which are free text. ## Wi-Fi (`SPAirPortDataType`) From 2cadd7da77b08ff27e704ba3ca03879766690d46 Mon Sep 17 00:00:00 2001 From: hideouts-io <83608068+hideouts-io@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:26:58 +0000 Subject: [PATCH 15/15] Give the Sync Services summary values every part of an explanation The empty sync log summary and the old Sync Services version now say why they matter and what to check, like the other limited-set values, and the value catalog covers them. --- .../Explanations/Values/SettingsAndSoftwareValueRules.swift | 6 +++++- SystemProfilerExplorerTests/ValueCatalogTests.swift | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift index f65c298..db3a63a 100644 --- a/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift +++ b/SystemProfilerExplorer/Core/Explanations/Values/SettingsAndSoftwareValueRules.swift @@ -83,7 +83,9 @@ let syncServicesSummaryValueRules: [ValueRule] = [ return .normal( "No sync log summary was recorded.", - detail: "That's expected on current macOS, which no longer uses Sync Services to sync contacts, calendars, and bookmarks; iCloud does that instead." + detail: "That's expected on current macOS, which no longer uses Sync Services to sync contacts, calendars, and bookmarks; iCloud does that instead.", + why: "An empty summary means there's no Sync Services activity to review, not that syncing is broken.", + action: "Nothing to do. If iCloud data isn't syncing, check iCloud in System Settings instead." ) }, @@ -95,6 +97,8 @@ let syncServicesSummaryValueRules: [ValueRule] = [ return .info( "This is the Mac OS X version Sync Services was built for, not the macOS on this Mac.", detail: "Apple introduced Sync Services in Mac OS X 10.4 and deprecated it in 10.7, so its reporter can show an old version even on current macOS.", + why: "It can look as if this Mac runs a very old system. It doesn't; the Software section shows the macOS version in use.", + action: "Nothing to do. Check the Software section for the version of macOS this Mac runs.", confidence: .likely(reasons: [ "Mac OS X \(context.reportedValue) is older than any macOS this app runs on.", "The value names a Mac OS X release from the period when Sync Services was current." diff --git a/SystemProfilerExplorerTests/ValueCatalogTests.swift b/SystemProfilerExplorerTests/ValueCatalogTests.swift index aaaac93..93e4609 100644 --- a/SystemProfilerExplorerTests/ValueCatalogTests.swift +++ b/SystemProfilerExplorerTests/ValueCatalogTests.swift @@ -478,6 +478,8 @@ private let settingsValueSamples: [ValueSample] = { samples += ["system_log_description", "sync_diagnostics_log_description"].map { ValueSample(.syncServices, ["_items", "[]", "description"], $0) } + samples.append(ValueSample(.syncServices, ["_items", "[]", "summary_of_sync_log"], "")) + samples.append(ValueSample(.syncServices, ["summary_os_version"], "10.6")) for field in ["se_in_restricted_mode", "se_prod_signed"] { samples += ["Yes", "No"].map { ValueSample(.secureElement, [field], $0) } }