import SwiftUI import WhatCableCore import WhatCableDarwinBackend // MARK: - Font scaling environment private struct FontScaleKey: EnvironmentKey { static let defaultValue: Double = 1.0 } extension EnvironmentValues { var fontScale: Double { get { self[FontScaleKey.self] } set { self[FontScaleKey.self] = newValue } } } /// View modifier that reads the fontScale environment and applies a scaled /// version of the given text style. Use `.scaledFont(.caption)` instead of /// `.font(.caption)` on any text that should respond to the slider. struct ScaledFontModifier: ViewModifier { @Environment(\.fontScale) private var scale let style: Font.TextStyle let design: Font.Design? let weight: Font.Weight? let monospacedDigit: Bool init(_ style: Font.TextStyle, design: Font.Design? = nil, weight: Font.Weight? = nil, monospacedDigit: Bool = false) { self.style = style self.design = design self.weight = weight self.monospacedDigit = monospacedDigit } func body(content: Content) -> some View { let baseSize = Self.baseSize(for: style) let size = baseSize * scale var font: Font = design != nil ? .system(size: size, design: design!) : .system(size: size) if let weight { font = font.weight(weight) } if monospacedDigit { font = font.monospacedDigit() } return content.font(font) } static func baseSize(for style: Font.TextStyle) -> Double { switch style { case .largeTitle: return 26 case .title: return 22 case .title2: return 17 case .title3: return 15 case .headline: return 13 case .body: return 13 case .callout: return 12 case .subheadline: return 11 case .footnote: return 10 case .caption: return 10 case .caption2: return 9 @unknown default: return 13 } } } extension View { func scaledFont(_ style: Font.TextStyle, design: Font.Design? = nil, weight: Font.Weight? = nil, monospacedDigit: Bool = false) -> some View { modifier(ScaledFontModifier(style, design: design, weight: weight, monospacedDigit: monospacedDigit)) } } struct ContentView: View { @StateObject private var portWatcher = USBCPortWatcher() @StateObject private var deviceWatcher = USBWatcher() @StateObject private var powerWatcher = PowerSourceWatcher() @StateObject private var pdWatcher = PDIdentityWatcher() @StateObject private var tbWatcher = ThunderboltWatcher() @EnvironmentObject private var refresh: RefreshSignal @ObservedObject private var settings = AppSettings.shared #if !WHATCABLE_MAS @ObservedObject private var updates = UpdateChecker.shared #endif @State private var portRefreshTask: Task? @State private var portPollTask: Task? private var showAdvanced: Bool { settings.showTechnicalDetails || refresh.optionHeld } var body: some View { Group { if refresh.showSettings { SettingsView(dismiss: { refresh.showSettings = false }) } else { mainContent } } .environment(\.fontScale, settings.fontSize) .onAppear { portWatcher.start() deviceWatcher.start() powerWatcher.start() pdWatcher.start() tbWatcher.start() startPortPoll() } .onDisappear { portRefreshTask?.cancel() portRefreshTask = nil portPollTask?.cancel() portPollTask = nil portWatcher.stop() deviceWatcher.stop() powerWatcher.stop() pdWatcher.stop() tbWatcher.stop() } .onChange(of: refresh.tick) { _, _ in portWatcher.refresh() powerWatcher.refresh() pdWatcher.refresh() tbWatcher.refresh() } // Port controller services don't fire IOKit match notifications when // their connection state flips, so we re-poll the port watcher // whenever any of the three live signals (device add/remove, power // source add/remove, PD identity add/remove) changes. Debounced so a // single plug event, which can fire all three within a few ms, // produces one refresh, with a backoff to catch slow controllers. .onChange(of: deviceWatcher.devices) { _, _ in scheduleLivePortRefresh() } .onChange(of: powerWatcher.sources) { _, _ in scheduleLivePortRefresh() } .onChange(of: pdWatcher.identities) { _, _ in scheduleLivePortRefresh() } } private func scheduleLivePortRefresh() { portRefreshTask?.cancel() portRefreshTask = Task { @MainActor in // Some port controllers (notably AppleHPMInterfaceType11 / MagSafe) // hold ConnectionActive=true for several seconds after unplug, so // we re-poll over a long backoff instead of guessing one delay. // refresh() is a no-op when nothing changed, so extra polls are // cheap and never cause flicker. for delay in [150, 500, 1500, 3000, 6000] { try? await Task.sleep(for: .milliseconds(delay)) guard !Task.isCancelled else { return } portWatcher.refresh() } } } /// Background safety net: poll the port watcher once a second while the /// popover is visible. Catches slow-updating controllers that don't fire /// IOKit interest notifications when their connection state flips, and /// covers state changes that happen outside the burst window triggered /// by scheduleLivePortRefresh. The conditional assignment in /// USBCPortWatcher.refresh() means polls are free when nothing changed. private func startPortPoll() { portPollTask?.cancel() portPollTask = Task { @MainActor in while !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) guard !Task.isCancelled else { return } portWatcher.refresh() } } } private var mainContent: some View { VStack(spacing: 0) { header #if !WHATCABLE_MAS if let update = updates.available { UpdateBanner(update: update) } #endif Divider() let visiblePorts = settings.hideEmptyPorts ? portWatcher.ports.filter { isPortLive($0) } : portWatcher.ports if visiblePorts.isEmpty { if portWatcher.ports.isEmpty { noPortsState } else { nothingConnectedState } } else { ScrollView { VStack(spacing: 12) { ForEach(visiblePorts) { port in PortCard( port: port, devices: matchingDevices(for: port), powerSources: powerWatcher.sources(for: port), identities: pdWatcher.identities(for: port), thunderboltSwitches: tbWatcher.switches, isLive: isPortLive(port), showAdvanced: showAdvanced ) } } .padding(12) } } Divider() footer } } private var header: some View { HStack { Image(systemName: "cable.connector.horizontal") .scaledFont(.title2) VStack(alignment: .leading, spacing: 2) { Text(AppInfo.name).scaledFont(.headline, weight: .bold) Text(AppInfo.tagline) .scaledFont(.caption) .foregroundStyle(.secondary) } Spacer() Button { refresh.bump() } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) .help(String(localized: "Refresh", bundle: .module)) Button { refresh.showSettings = true } label: { Image(systemName: "gearshape") } .buttonStyle(.borderless) .help(String(localized: "Settings", bundle: .module)) } .padding(12) .background( Button("") { refresh.showSettings = true } .keyboardShortcut(",", modifiers: .command) .opacity(0) .allowsHitTesting(false) ) } private var footer: some View { HStack { Toggle(String(localized: "Show technical details", bundle: .module), isOn: $settings.showTechnicalDetails) .toggleStyle(.switch) .controlSize(.mini) .scaledFont(.caption) Spacer() Text(String(localized: "\(deviceWatcher.devices.count) USB devices", bundle: .module)) .scaledFont(.caption) .foregroundStyle(.secondary) Text(verbatim: "·").scaledFont(.caption).foregroundStyle(.secondary) Text(verbatim: "v\(AppInfo.version) · \(AppInfo.credit)") .scaledFont(.caption) .foregroundStyle(.tertiary) Text(verbatim: "·").scaledFont(.caption).foregroundStyle(.secondary) Button(String(localized: "Quit", bundle: .module)) { NSApplication.shared.terminate(nil) } .buttonStyle(.borderless) .scaledFont(.caption) .foregroundStyle(.secondary) } .padding(.horizontal, 12) .padding(.vertical, 8) } private var noPortsState: some View { VStack(spacing: 8) { Image(systemName: "powerplug") .scaledFont(.largeTitle) .foregroundStyle(.secondary) Text(String(localized: "No USB-C ports detected", bundle: .module)) .scaledFont(.headline, weight: .bold) Text(String(localized: "This Mac doesn't seem to expose its port-controller services. Hit refresh, or check System Information > USB.", bundle: .module)) .scaledFont(.caption) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 32) } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.vertical, 40) } private var nothingConnectedState: some View { VStack(spacing: 8) { Image(systemName: "cable.connector.slash") .scaledFont(.largeTitle) .foregroundStyle(.secondary) Text(String(localized: "Nothing connected", bundle: .module)) .scaledFont(.headline, weight: .bold) Text(String(localized: "\(portWatcher.ports.count) USB-C ports detected, but nothing is currently plugged in. Turn off \"Hide empty ports\" in Settings to see them.", bundle: .module)) .scaledFont(.caption) .multilineTextAlignment(.center) .foregroundStyle(.secondary) .padding(.horizontal, 32) } .frame(maxWidth: .infinity, maxHeight: .infinity) } /// Live-signal check delegating to the pure helper in `WhatCableCore`, /// so the same rules apply to both the GUI and any test harness. private func isPortLive(_ port: USBCPort) -> Bool { WhatCableCore.isPortLive( port: port, powerSources: powerWatcher.sources(for: port), identities: pdWatcher.identities(for: port), matchingDevices: matchingDevices(for: port) ) } /// Match USB devices to their physical port. The IOKit relationship /// isn't direct: USB devices live under the XHCI controller subtree, /// physical ports under the SPMI/HPM subtree. Two strategies, in order: /// /// 1. `controllerPortName`: each XHCI controller exposes a `UsbIOPort` /// property whose path ends in the physical port's service name /// (e.g. ".../Port-USB-C@1"). When present, this gives a direct /// link with no ambiguity. /// 2. `busIndex`: derived from the `hpm` ancestor on the port side /// and the XHCI controller's `locationID` upper byte on the device /// side. Fragile, breaks when devices sit deeper behind a hub /// than the parent walk reaches, or when hpm numbering diverges /// from controller numbering. /// /// If neither is available we return [] rather than dumping every /// device onto the port. Showing all devices on every active USB port /// is worse than showing none, and it caused the bug that issue #21 /// reported. private func matchingDevices(for port: USBCPort) -> [USBDevice] { port.matchingDevices(from: deviceWatcher.devices) } } #if !WHATCABLE_MAS struct UpdateBanner: View { let update: AvailableUpdate @ObservedObject private var installer = Installer.shared var body: some View { HStack(spacing: 8) { Image(systemName: "arrow.down.circle.fill") .foregroundStyle(.tint) VStack(alignment: .leading, spacing: 2) { Text(String(localized: "WhatCable \(update.version) is available", bundle: .module)) .scaledFont(.callout, weight: .bold) statusLine .scaledFont(.caption).foregroundStyle(.secondary) } Spacer() actionButtons } .padding(.horizontal, 12) .padding(.vertical, 8) .background(Color.accentColor.opacity(0.12)) } @ViewBuilder private var statusLine: some View { switch installer.state { case .idle: Text(String(localized: "You're on \(AppInfo.version)", bundle: .module)) case .downloading: Text(String(localized: "Downloading…", bundle: .module)) case .verifying: Text(String(localized: "Verifying signature…", bundle: .module)) case .installing: Text(String(localized: "Installing, WhatCable will relaunch", bundle: .module)) case .failed(let message): Text(String(localized: "Install failed: \(message)", bundle: .module)).foregroundStyle(.red) } } @ViewBuilder private var actionButtons: some View { switch installer.state { case .idle, .failed: HStack(spacing: 6) { Button(String(localized: "View release", bundle: .module)) { NSWorkspace.shared.open(update.url) } .buttonStyle(.bordered) .controlSize(.small) if update.downloadURL != nil { Button(String(localized: "Install update", bundle: .module)) { Installer.shared.install(update) } .buttonStyle(.borderedProminent) .controlSize(.small) } } case .downloading, .verifying, .installing: ProgressView().controlSize(.small) } } } #endif // !WHATCABLE_MAS // MARK: - Port card struct PortCard: View { let port: USBCPort let devices: [USBDevice] let powerSources: [PowerSource] let identities: [PDIdentity] let thunderboltSwitches: [ThunderboltSwitch] /// Authoritative connection state derived from the live IOKit watchers, /// passed in from the parent so we don't have to consult them from here /// and so PortSummary doesn't fall back to the unreliable /// `port.connectionActive` property. let isLive: Bool let showAdvanced: Bool @State private var reportingCable: PDIdentity? var summary: PortSummary { PortSummary( port: port, sources: powerSources, identities: identities, devices: devices, thunderboltSwitches: thunderboltSwitches, isConnectedOverride: isLive ) } /// Switches in the chain from this port's host root to the deepest /// connected device. Empty if the port doesn't map to any TB switch. var thunderboltChain: [ThunderboltSwitch] { guard let socketID = ThunderboltTopology.socketID(fromServiceName: port.serviceName), let root = ThunderboltTopology.hostRoot(forSocketID: socketID, in: thunderboltSwitches) else { return [] } return ThunderboltTopology.chain(from: root, in: thunderboltSwitches) } private var cableEmarker: PDIdentity? { identities.first { $0.endpoint == .sopPrime || $0.endpoint == .sopDoublePrime } } var body: some View { VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 12) { Image(systemName: summary.icon) .scaledFont(.title2) .foregroundStyle(summary.iconColor) .frame(width: 36) VStack(alignment: .leading, spacing: 2) { Text(port.portDescription ?? port.serviceName) .scaledFont(.caption) .foregroundStyle(.secondary) Text(summary.headline) .scaledFont(.title3, weight: .bold) Text(summary.subtitle) .scaledFont(.callout) .foregroundStyle(.secondary) } Spacer() } if !summary.bullets.isEmpty { VStack(alignment: .leading, spacing: 4) { ForEach(summary.bullets, id: \.self) { bullet in HStack(alignment: .top, spacing: 6) { Text(verbatim: "•").foregroundStyle(.secondary) Text(bullet).scaledFont(.callout) Spacer() } } } .padding(.leading, 48) } if !devices.isEmpty { VStack(alignment: .leading, spacing: 4) { Text(String(localized: "Connected devices", bundle: .module)) .scaledFont(.caption).foregroundStyle(.secondary) ForEach(devices) { d in let name = d.productName ?? String(localized: "Unknown", bundle: .module) Text(verbatim: "• \(name) - \(d.speedLabel)") .scaledFont(.callout) } } .padding(.leading, 48) } if let diag = ChargingDiagnostic(port: port, sources: powerSources, identities: identities) { DiagnosticBanner(diagnostic: diag) .padding(.leading, 48) } if !powerSources.isEmpty { PowerSourceList(sources: powerSources) .padding(.leading, 48) } if let cable = cableEmarker { let trust = CableTrustReport(identity: cable) if !trust.isEmpty { TrustFlagsCard(flags: trust.flags) .padding(.leading, 48) } HStack { Spacer() Button { reportingCable = cable } label: { Label(String(localized: "Report this cable", bundle: .module), systemImage: "exclamationmark.bubble") .scaledFont(.caption) } .buttonStyle(.borderless) .help(String(localized: "File a GitHub issue with this cable's e-marker fingerprint", bundle: .module)) } .padding(.leading, 48) } if showAdvanced { Divider() AdvancedPortDetails( port: port, cableEmarker: cableEmarker, thunderboltChain: thunderboltChain ) } } .padding(14) .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 10)) .sheet(item: $reportingCable) { cable in CableReportSheet(cableIdentity: cable) { reportingCable = nil } } } } struct DiagnosticBanner: View { let diagnostic: ChargingDiagnostic var body: some View { HStack(alignment: .top, spacing: 8) { Image(systemName: diagnostic.icon) .foregroundStyle(diagnostic.isWarning ? Color.orange : Color.green) .scaledFont(.callout) VStack(alignment: .leading, spacing: 2) { Text(diagnostic.summary).scaledFont(.callout, weight: .bold) Text(diagnostic.detail).scaledFont(.caption).foregroundStyle(.secondary) } Spacer() } .padding(10) .background( (diagnostic.isWarning ? Color.orange : Color.green) .opacity(0.1), in: RoundedRectangle(cornerRadius: 8) ) } } struct PowerSourceList: View { let sources: [PowerSource] var body: some View { VStack(alignment: .leading, spacing: 6) { ForEach(sources) { src in if !src.options.isEmpty { VStack(alignment: .leading, spacing: 2) { let srcName = src.name Text(String(localized: "\(srcName) profiles", bundle: .module)) .scaledFont(.caption).foregroundStyle(.secondary) ForEach(src.options.sorted(by: { $0.voltageMV < $1.voltageMV }), id: \.self) { opt in let isWinning = opt == src.winning HStack(spacing: 6) { Image(systemName: isWinning ? "checkmark.circle.fill" : "circle") .foregroundStyle(isWinning ? Color.green : Color.secondary) .scaledFont(.caption) Text(verbatim: "\(opt.voltsLabel) @ \(opt.ampsLabel) - \(opt.wattsLabel)") .scaledFont(.callout, monospacedDigit: true) if isWinning { Text(String(localized: "active", bundle: .module)).scaledFont(.caption2).foregroundStyle(.green) } Spacer() } } } } } } } } struct AdvancedPortDetails: View { let port: USBCPort let cableEmarker: PDIdentity? let thunderboltChain: [ThunderboltSwitch] var body: some View { VStack(alignment: .leading, spacing: 8) { group(String(localized: "Connection", bundle: .module)) { row(String(localized: "Active", bundle: .module), bool(port.connectionActive)) row(String(localized: "Active cable electronics", bundle: .module), bool(port.activeCable)) row(String(localized: "Optical", bundle: .module), bool(port.opticalCable)) row(String(localized: "USB active", bundle: .module), bool(port.usbActive)) row(String(localized: "SuperSpeed", bundle: .module), bool(port.superSpeedActive)) row(String(localized: "Plug events", bundle: .module), port.plugEventCount.map(String.init) ?? "—") } group(String(localized: "Transports", bundle: .module)) { row(String(localized: "Supported", bundle: .module), port.transportsSupported.joined(separator: ", ")) row(String(localized: "Provisioned", bundle: .module), port.transportsProvisioned.joined(separator: ", ")) row(String(localized: "Active", bundle: .module), port.transportsActive.isEmpty ? "—" : port.transportsActive.joined(separator: ", ")) } if let v2 = cableEmarker?.activeCableVDO2 { ActiveCableVDO2Section(vdo2: v2) } if !thunderboltChain.isEmpty { ThunderboltFabricSection(chain: thunderboltChain) } let rawCount = port.rawProperties.count DisclosureGroup(String(localized: "All raw IOKit properties (\(rawCount))", bundle: .module)) { VStack(alignment: .leading, spacing: 2) { ForEach(port.rawProperties.sorted(by: { $0.key < $1.key }), id: \.key) { kv in HStack(alignment: .top) { Text(kv.key).scaledFont(.caption, design: .monospaced) .foregroundStyle(.secondary) .frame(width: 200, alignment: .leading) Text(kv.value).scaledFont(.caption, design: .monospaced) .textSelection(.enabled) Spacer() } } } .padding(.top, 4) } .scaledFont(.caption) } } private func group(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title).scaledFont(.caption, weight: .bold).foregroundStyle(.secondary) content() } } private func row(_ key: String, _ value: String) -> some View { HStack { Text(key).scaledFont(.caption).foregroundStyle(.secondary).frame(width: 120, alignment: .leading) Text(value).scaledFont(.caption, design: .monospaced) Spacer() } } private func bool(_ v: Bool?) -> String { guard let v else { return "—" } return v ? String(localized: "Yes", bundle: .module) : String(localized: "No", bundle: .module) } } /// Renders every field in Active Cable VDO 2. Hidden behind the /// existing "show technical details" toggle. The bullet list above the /// fold already surfaces the user-facing essentials (medium, active /// element, optical isolation), so this section is the deep view for /// people who want to see USB protocol support, lane count, idle power, /// thermal limits, etc. struct ActiveCableVDO2Section: View { let vdo2: PDVDO.ActiveCableVDO2 var body: some View { VStack(alignment: .leading, spacing: 2) { Text("Active cable (VDO 2)") .scaledFont(.caption, weight: .bold) .foregroundStyle(.secondary) row("Physical connection", vdo2.physicalConnection.label) row("Active element", vdo2.activeElement.label) row("Optically isolated", bool(vdo2.opticallyIsolated)) row("USB lanes", vdo2.twoLanesSupported ? "Two" : "One") row("USB Gen", vdo2.usbGen2OrHigher ? "Gen 2 or higher" : "Gen 1") row("USB4 supported", bool(vdo2.usb4Supported)) row("USB 3.2 supported", bool(vdo2.usb32Supported)) row("USB 2.0 supported", bool(vdo2.usb2Supported)) row("USB 2.0 hub hops", String(vdo2.usb2HubHopsConsumed)) row("USB4 asymmetric", bool(vdo2.usb4AsymmetricMode)) row("U3 to U0 transition", vdo2.u3ToU0TransitionThroughU3S ? "Through U3S" : "Direct") row("Idle power (U3/CLd)", vdo2.u3CLdPower.label) row("Max operating temp", temp(vdo2.maxOperatingTempC)) row("Shutdown temp", temp(vdo2.shutdownTempC)) } } private func row(_ key: String, _ value: String) -> some View { HStack { Text(key).scaledFont(.caption).foregroundStyle(.secondary).frame(width: 160, alignment: .leading) Text(value).scaledFont(.caption, design: .monospaced) Spacer() } } private func bool(_ v: Bool) -> String { v ? "Yes" : "No" } /// 0 in this field means "not specified" per the spec text. Show /// the dash placeholder rather than the misleading literal "0°C". private func temp(_ v: Int) -> String { v == 0 ? "—" : "\(v)°C" } } /// Compact tree view of the Thunderbolt fabric for one port. Shows the /// host root, every downstream switch in the chain, and the active /// downstream lane port's link state for each hop. Hidden behind the /// existing "show technical details" toggle. struct ThunderboltFabricSection: View { let chain: [ThunderboltSwitch] var body: some View { VStack(alignment: .leading, spacing: 4) { Text(String(localized: "Thunderbolt fabric", bundle: .module)) .scaledFont(.caption, weight: .bold).foregroundStyle(.secondary) ForEach(Array(chain.enumerated()), id: \.element.id) { index, sw in hopRow(sw, index: index) } } } @ViewBuilder private func hopRow(_ sw: ThunderboltSwitch, index: Int) -> some View { let indent = String(repeating: " ", count: index) let arrow = index == 0 ? "" : "↳ " let name = sw.isHostRoot ? String(localized: "Host (\(sw.className))", bundle: .module) : ThunderboltLabels.deviceName(for: sw) let port = ThunderboltTopology.activeDownstreamLanePort(sw) let linkLabel = port.flatMap { ThunderboltLabels.linkLabel(for: $0) } ?? String(localized: "no active link", bundle: .module) HStack(alignment: .top) { Text(verbatim: "\(indent)\(arrow)\(name)") .scaledFont(.caption, design: .monospaced) Spacer() Text(linkLabel) .scaledFont(.caption, design: .monospaced) .foregroundStyle(.secondary) } } } private struct TrustFlagsCard: View { let flags: [TrustFlag] var body: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) Text(String(localized: "Cable trust signals", bundle: .module)) .scaledFont(.caption, weight: .bold) .foregroundStyle(.secondary) } ForEach(flags, id: \.code) { flag in VStack(alignment: .leading, spacing: 2) { Text(flag.title).scaledFont(.callout, weight: .bold) Text(flag.detail) .scaledFont(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) } }