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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions Flowlight/Analysis/Coverage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import Foundation

/// How much of one app's traffic Flowlight can actually account for, and what is missing.
///
/// The app says it sees every connection, and then the honest paragraphs elsewhere list the exceptions:
/// traffic from before the filter started, system services a content filter never sees, a Mac where another
/// filter owns the only slot, apps that pin their certificates, QUIC the proxy is never offered, an agent's
/// MCP server talking over a pipe. All of that is true and all of it is somewhere else, so reading any screen
/// correctly means having read the documentation first.
///
/// This turns it into a per-app answer. Three separate questions, because they fail independently: is the
/// traffic *seen* at all, is the destination *named*, and is the content *readable*. An app can be fully seen
/// and entirely unnamed, or named and unreadable, and lumping those together is how a coverage number becomes
/// a reassurance rather than a fact.
struct AppCoverage: Identifiable, Equatable, Sendable {
var bundleID: String
var appName: String
var bytes: Int64
/// How its flows reach Flowlight.
var capture: Capture
/// The share of this app's bytes whose destination has a hostname rather than a bare address.
var named: Double
/// Whether anything of this app's has been decrypted, and why not when it hasn't.
var inspection: Inspection
var id: String { bundleID }

enum Capture: Equatable, Sendable {
/// The Network Extension sees flows as they open.
case filter
/// The sampler reads counters once a second: a connection that opens and closes between two readings
/// is never counted, which is a gap no percentage can show.
case sampler
/// The filter is the chosen source but isn't delivering — another content filter holds the slot, or it
/// hasn't been installed — so what is on screen came from the sampler instead.
case fellBack
case demo
}

enum Inspection: Equatable, Sendable {
/// Not turned on. Nothing is decrypted for anyone.
case off
/// On, and this app's requests are being read.
case reading
/// On, but nothing of this app's has arrived: it was started without the proxy, or it ignores proxy
/// settings, or it pins its certificates and was passed through untouched.
case notRouted
/// Deliberately excluded — Apple services, password managers, anything on the never-inspect list.
case excluded
}

/// Whether anything here is worth a user's attention. An app that is seen, named and either read or not
/// meant to be read is covered; everything else has a gap worth naming.
var isComplete: Bool {
capture == .filter && named > 0.99 && (inspection == .reading || inspection == .excluded || inspection == .off)
}
}

enum CoverageReport {
/// Builds one row per app from a report's breakdown, the apps whose traffic has been decrypted, and the
/// state of the two engines.
///
/// `named` is computed over bytes rather than flows on purpose: one unnamed connection carrying a gigabyte
/// is a bigger hole than a hundred unnamed connections carrying a kilobyte each, and a count would rank
/// them the other way round.
static func build(rows: [BreakdownRow], inspected: Set<String>, excluded: Set<String>,
mode: CaptureMode, fellBack: Bool, inspectionOn: Bool, isDemo: Bool,
limit: Int = 60) -> [AppCoverage] {
var bytes: [String: Int64] = [:]
var namedBytes: [String: Int64] = [:]
var names: [String: String] = [:]
for row in rows where !row.bundleID.isEmpty {
bytes[row.bundleID, default: 0] += row.counters.total
if !row.domain.isEmpty { namedBytes[row.bundleID, default: 0] += row.counters.total }
if names[row.bundleID] == nil || names[row.bundleID]?.isEmpty == true {
names[row.bundleID] = row.appName.isEmpty ? row.bundleID : row.appName
}
}
let capture: AppCoverage.Capture = isDemo ? .demo
: mode == .nettop ? .sampler : (fellBack ? .fellBack : .filter)

return bytes.map { bundleID, total in
let inspection: AppCoverage.Inspection
if isDemo || !inspectionOn { inspection = .off }
else if inspected.contains(bundleID) { inspection = .reading }
else if excluded.contains(bundleID) { inspection = .excluded }
else { inspection = .notRouted }
return AppCoverage(bundleID: bundleID, appName: names[bundleID] ?? bundleID, bytes: total,
capture: capture,
named: total > 0 ? Double(namedBytes[bundleID] ?? 0) / Double(total) : 0,
inspection: inspection)
}
// Biggest first: a gap matters in proportion to what is going through it.
.sorted { ($0.bytes, $1.appName) > ($1.bytes, $0.appName) }
.prefix(limit)
.map { $0 }
}

/// The share of all bytes in the report that sit behind a complete row. Deliberately not an average of the
/// per-app percentages: an app moving a gigabyte and an app moving a kilobyte are not half the picture each.
static func overall(_ rows: [AppCoverage]) -> Double {
let total = rows.reduce(Int64(0)) { $0 + $1.bytes }
guard total > 0 else { return 0 }
let complete = rows.filter(\.isComplete).reduce(Int64(0)) { $0 + $1.bytes }
return Double(complete) / Double(total)
}
}
16 changes: 13 additions & 3 deletions Flowlight/App/AppNavigation.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import SwiftUI

enum SidebarItem: String, CaseIterable, Identifiable {
case live, agents, reports, alerts, rules, ask, inspect, devices, capture
case live, agents, reports, alerts, rules, ask, inspect, devices, coverage, capture
var id: String { rawValue }
/// The part of the documentation that explains this screen.
///
Expand All @@ -18,6 +18,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
case .ask: return "ask"
case .inspect: return "inspection"
case .devices: return "devices"
case .coverage: return "coverage"
case .capture: return "capture"
}
}
Expand All @@ -34,6 +35,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
case .ask: return "Ask"
case .inspect: return "Inspect"
case .devices: return "Devices"
case .coverage: return "Coverage"
case .capture: return "Capture"
}
}
Expand All @@ -47,6 +49,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
case .rules: return L("Rules")
case .ask: return L("Ask")
case .devices: return L("Devices")
case .coverage: return L("Coverage")
case .inspect: return L("Inspect")
case .capture: return L("Capture")
}
Expand All @@ -60,18 +63,25 @@ enum SidebarItem: String, CaseIterable, Identifiable {
case .rules: return "hand.raised"
case .ask: return "text.bubble"
case .devices: return "dot.radiowaves.left.and.right"
case .coverage: return "circle.dashed.inset.filled"
case .inspect: return "lock.open.display"
case .capture: return "antenna.radiowaves.left.and.right"
}
}
var shortcut: KeyEquivalent { KeyEquivalent(Character(String((Self.allCases.firstIndex(of: self) ?? 0) + 1))) }
/// ⌘1…⌘9 and then ⌘0, the way every browser numbers its tabs. Nil past the tenth: there is no eleventh
/// digit, and the arithmetic that assumed there was turned `10` into a `Character` and trapped the moment
/// a tenth screen was added.
var shortcut: KeyEquivalent? {
guard let index = Self.allCases.firstIndex(of: self), index < 10 else { return nil }
return KeyEquivalent(Character("\(index == 9 ? 0 : index + 1)"))
}

var section: SidebarSection {
switch self {
case .live, .agents, .reports, .alerts: return .traffic
case .ask, .inspect: return .investigate
case .rules: return .control
case .devices, .capture: return .sources
case .devices, .coverage, .capture: return .sources
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion Flowlight/App/FlowlightApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,8 @@ struct FlowlightApp: App {
}
CommandGroup(before: .sidebar) {
ForEach(SidebarItem.allCases) { item in
Button(item.title) { nav.selection = item }.keyboardShortcut(item.shortcut, modifiers: .command)
Button(item.title) { nav.selection = item }
.keyboardShortcut(item.shortcut ?? .init(" "), modifiers: item.shortcut == nil ? [] : .command)
}
Divider()
Button(focus.isOn ? L("Turn Focus Off") : L("Turn Focus On")) { focus.isOn.toggle() }
Expand Down
26 changes: 26 additions & 0 deletions Flowlight/Localization/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"Rules" = "Regeln";
"Ask" = "Fragen";
"Devices" = "Geräte";
"Coverage" = "Abdeckung";
"Inspect" = "Inspizieren";
"Capture" = "Erfassung";
"Traffic" = "Datenverkehr";
Expand Down Expand Up @@ -181,6 +182,7 @@
"The provider returned HTTP %lld. %@" = "Der Anbieter hat HTTP %lld zurückgegeben. %@";
"The provider's answer wasn't in a shape Flowlight could read." = "Die Antwort des Anbieters hatte keine Form, die Flowlight lesen konnte.";
"No endpoint is set for %@." = "Für %@ ist kein Endpunkt festgelegt.";
"%@ is not an https:// address. A key and your question would cross the network in the clear, so Flowlight won't send them." = "%@ ist keine https://-Adresse. Ein Schlüssel und Ihre Frage würden im Klartext über das Netzwerk laufen, deshalb sendet Flowlight sie nicht.";
"%@ needs an API key. Add one in Settings — it goes to your login Keychain." = "%@ benötigt einen API-Schlüssel. Fügen Sie in den Einstellungen einen hinzu – er wird im Anmeldeschlüsselbund gespeichert.";
"The on-device model isn't reached over HTTP." = "Das Modell auf dem Gerät wird nicht über HTTP erreicht.";
"I ran out of steps before I could answer that. Try asking something narrower." = "Die Schritte waren aufgebraucht, bevor eine Antwort möglich war. Fragen Sie etwas Engeres.";
Expand Down Expand Up @@ -675,6 +677,30 @@
"Error" = "Fehler";
"OK" = "OK";

/* UI/CoverageView.swift */
"Nothing recorded yet" = "Noch nichts aufgezeichnet";
"Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "Die Abdeckung wird aus dem Datenverkehr berechnet, den Flowlight schon aufgezeichnet hat. Lassen Sie es einen Moment laufen.";
"%@ of traffic fully accounted for" = "%@ des Datenverkehrs vollständig erfasst";
"How much of this Mac is covered" = "Wie viel von diesem Mac abgedeckt ist";
"HTTPS inspection is on" = "Die HTTPS-Inspektion ist ein";
"HTTPS inspection is off" = "Die HTTPS-Inspektion ist aus";
"What an app sent is readable only where the app was routed through the proxy. Anything else is counted and named, but its contents were never offered to Flowlight." = "Was eine App gesendet hat, ist nur dort lesbar, wo die App über den Proxy geleitet wurde. Alles andere wird gezählt und benannt, aber sein Inhalt wurde Flowlight nie angeboten.";
"Flowlight sees which connections were made and where to, but not what was inside them. Nothing is decrypted until you turn inspection on." = "Flowlight sieht, welche Verbindungen zustande kamen und wohin, aber nicht, was darin war. Es wird nichts entschlüsselt, solange Sie die Inspektion nicht einschalten.";
"What no engine can see" = "Was keine Engine sehen kann";
"Traffic from before capture started, system services that content filters are never shown, apps that pin their certificates, QUIC the proxy is never offered, and an agent's local MCP server talking over a pipe. None of these appear anywhere in Flowlight, so they are absent from these figures too." = "Datenverkehr von vor dem Start der Erfassung, Systemdienste, die Inhaltsfiltern nie gezeigt werden, Apps, die ihre Zertifikate pinnen, QUIC, das dem Proxy nie angeboten wird, und der lokale MCP-Server eines Agenten, der über eine Pipe spricht. Nichts davon erscheint irgendwo in Flowlight, also fehlt es auch in diesen Zahlen.";
"Sampling with nettop, not the filter" = "Abtastung mit nettop, nicht mit dem Filter";
"The filter sees connections as they open" = "Der Filter sieht Verbindungen beim Öffnen";
"Every eligible TCP and UDP flow is attributed as it opens, including connections too short for a sampler to catch." = "Jeder geeignete TCP- und UDP-Flow wird beim Öffnen zugeordnet, auch Verbindungen, die für den Sampler zu kurz sind.";
"The filter isn't answering, so these figures come from the sampler: a connection that opens and closes between two readings is missing entirely, and no percentage below can show it." = "Der Filter antwortet nicht, diese Zahlen stammen also vom Sampler: Eine Verbindung, die sich zwischen zwei Messungen öffnet und wieder schließt, fehlt ganz, und kein Prozentwert unten kann sie zeigen.";
"Byte counts are exact, but a connection that opens and closes between two readings is never recorded — a quick DNS lookup, a fast API call, a script that runs curl and exits. Only the filter sees those." = "Die Byte-Zähler sind genau, aber eine Verbindung, die sich zwischen zwei Messungen öffnet und wieder schließt, wird nie aufgezeichnet – eine schnelle DNS-Abfrage, ein kurzer API-Aufruf, ein Skript, das curl ausführt und endet. Nur der Filter sieht das.";
"By app" = "Nach App";
"Share of this app's bytes whose destination has a hostname rather than a bare address." = "Anteil der Bytes dieser App, deren Ziel einen Hostnamen hat und nicht bloß eine Adresse.";
"%@ of its bytes went to addresses with no hostname" = "%@ ihrer Bytes gingen an Adressen ohne Hostnamen";
"nothing of its traffic reached the proxy, so no contents were read" = "nichts von ihrem Datenverkehr erreichte den Proxy, es wurden also keine Inhalte gelesen";
"it is on the Never decrypted list, so its contents are deliberately not read" = "sie steht auf der Liste „Nie entschlüsselt“, ihre Inhalte werden also absichtlich nicht gelesen";
"short connections may be missing entirely" = "kurze Verbindungen können ganz fehlen";
"Seen as it happened, named, and readable." = "Beim Entstehen gesehen, benannt und lesbar.";

/* UI/DevicesView.swift */
"USB" = "USB";
"Not watching" = "Nicht überwacht";
Expand Down
26 changes: 26 additions & 0 deletions Flowlight/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"Rules" = "Rules";
"Ask" = "Ask";
"Devices" = "Devices";
"Coverage" = "Coverage";
"Inspect" = "Inspect";
"Capture" = "Capture";
"Traffic" = "Traffic";
Expand Down Expand Up @@ -183,6 +184,7 @@
"The provider returned HTTP %lld. %@" = "The provider returned HTTP %lld. %@";
"The provider's answer wasn't in a shape Flowlight could read." = "The provider's answer wasn't in a shape Flowlight could read.";
"No endpoint is set for %@." = "No endpoint is set for %@.";
"%@ is not an https:// address. A key and your question would cross the network in the clear, so Flowlight won't send them." = "%@ is not an https:// address. A key and your question would cross the network in the clear, so Flowlight won't send them.";
"%@ needs an API key. Add one in Settings — it goes to your login Keychain." = "%@ needs an API key. Add one in Settings — it goes to your login Keychain.";
"The on-device model isn't reached over HTTP." = "The on-device model isn't reached over HTTP.";
"I ran out of steps before I could answer that. Try asking something narrower." = "I ran out of steps before I could answer that. Try asking something narrower.";
Expand Down Expand Up @@ -677,6 +679,30 @@
"Error" = "Error";
"OK" = "OK";

/* UI/CoverageView.swift */
"Nothing recorded yet" = "Nothing recorded yet";
"Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment." = "Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment.";
"%@ of traffic fully accounted for" = "%@ of traffic fully accounted for";
"How much of this Mac is covered" = "How much of this Mac is covered";
"HTTPS inspection is on" = "HTTPS inspection is on";
"HTTPS inspection is off" = "HTTPS inspection is off";
"What an app sent is readable only where the app was routed through the proxy. Anything else is counted and named, but its contents were never offered to Flowlight." = "What an app sent is readable only where the app was routed through the proxy. Anything else is counted and named, but its contents were never offered to Flowlight.";
"Flowlight sees which connections were made and where to, but not what was inside them. Nothing is decrypted until you turn inspection on." = "Flowlight sees which connections were made and where to, but not what was inside them. Nothing is decrypted until you turn inspection on.";
"What no engine can see" = "What no engine can see";
"Traffic from before capture started, system services that content filters are never shown, apps that pin their certificates, QUIC the proxy is never offered, and an agent's local MCP server talking over a pipe. None of these appear anywhere in Flowlight, so they are absent from these figures too." = "Traffic from before capture started, system services that content filters are never shown, apps that pin their certificates, QUIC the proxy is never offered, and an agent's local MCP server talking over a pipe. None of these appear anywhere in Flowlight, so they are absent from these figures too.";
"Sampling with nettop, not the filter" = "Sampling with nettop, not the filter";
"The filter sees connections as they open" = "The filter sees connections as they open";
"Every eligible TCP and UDP flow is attributed as it opens, including connections too short for a sampler to catch." = "Every eligible TCP and UDP flow is attributed as it opens, including connections too short for a sampler to catch.";
"The filter isn't answering, so these figures come from the sampler: a connection that opens and closes between two readings is missing entirely, and no percentage below can show it." = "The filter isn't answering, so these figures come from the sampler: a connection that opens and closes between two readings is missing entirely, and no percentage below can show it.";
"Byte counts are exact, but a connection that opens and closes between two readings is never recorded — a quick DNS lookup, a fast API call, a script that runs curl and exits. Only the filter sees those." = "Byte counts are exact, but a connection that opens and closes between two readings is never recorded — a quick DNS lookup, a fast API call, a script that runs curl and exits. Only the filter sees those.";
"By app" = "By app";
"Share of this app's bytes whose destination has a hostname rather than a bare address." = "Share of this app's bytes whose destination has a hostname rather than a bare address.";
"%@ of its bytes went to addresses with no hostname" = "%@ of its bytes went to addresses with no hostname";
"nothing of its traffic reached the proxy, so no contents were read" = "nothing of its traffic reached the proxy, so no contents were read";
"it is on the Never decrypted list, so its contents are deliberately not read" = "it is on the Never decrypted list, so its contents are deliberately not read";
"short connections may be missing entirely" = "short connections may be missing entirely";
"Seen as it happened, named, and readable." = "Seen as it happened, named, and readable.";

/* UI/DevicesView.swift */
"USB" = "USB";
"Not watching" = "Not watching";
Expand Down
Loading
Loading