From 5c1c2b15073ffed4cc89ed9e901c54812c072b78 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Tue, 3 Nov 2026 20:56:51 -0800 Subject: [PATCH 1/2] Release Flowlight 0.16.11 Unify visualization routes as cubic curves, add multi-select endpoint scoping, preserve upstream launcher lineage, mask topology transitions, refresh the homepage screenshot, and repair responsive installation and anchor menus. Co-Authored-By: Claude Code --- Flowlight.xcodeproj/project.pbxproj | 12 +- Flowlight/Analysis/AgentAttributor.swift | 12 +- Flowlight/Analysis/MCPAttribution.swift | 9 +- Flowlight/App/DemoData.swift | 7 +- .../Localization/de.lproj/Localizable.strings | 1 + .../Localization/en.lproj/Localizable.strings | 1 + .../Localization/es.lproj/Localizable.strings | 1 + .../Localization/fr.lproj/Localizable.strings | 1 + .../Localization/it.lproj/Localizable.strings | 1 + .../Localization/ja.lproj/Localizable.strings | 1 + .../Localization/ko.lproj/Localizable.strings | 1 + .../pt-PT.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + Flowlight/Storage/TrafficDatabase.swift | 73 ++- .../UI/Visualization/AITrafficGraph.swift | 68 ++- Flowlight/UI/Visualization/TrafficGraph.swift | 138 ++++-- .../UI/Visualization/TrafficGraphLayout.swift | 443 ++++++++---------- Flowlight/UI/VisualizeView.swift | 240 +++++----- FlowlightTests/AgentLauncherTests.swift | 15 + FlowlightTests/DatabaseTests.swift | 37 +- FlowlightTests/DemoDataTests.swift | 37 ++ FlowlightTests/MCPAttributionTests.swift | 12 +- FlowlightTests/TrafficGraphTests.swift | 156 +++++- Shared/AgentLauncher.swift | 32 +- Shared/TrafficModels.swift | 7 + docs/404.html | 2 +- docs/about/index.html | 2 +- docs/de/about/index.html | 2 +- docs/de/docs/index.html | 4 +- docs/de/index.html | 6 +- docs/de/privacy/index.html | 2 +- docs/de/threat-model/index.html | 2 +- docs/docs/index.html | 4 +- docs/es/about/index.html | 2 +- docs/es/docs/index.html | 4 +- docs/es/index.html | 6 +- docs/es/privacy/index.html | 2 +- docs/es/threat-model/index.html | 2 +- docs/fr/about/index.html | 2 +- docs/fr/docs/index.html | 4 +- docs/fr/index.html | 6 +- docs/fr/privacy/index.html | 2 +- docs/fr/threat-model/index.html | 2 +- docs/index.html | 6 +- docs/it/about/index.html | 2 +- docs/it/docs/index.html | 4 +- docs/it/index.html | 6 +- docs/it/privacy/index.html | 2 +- docs/it/threat-model/index.html | 2 +- docs/ja/about/index.html | 2 +- docs/ja/docs/index.html | 4 +- docs/ja/index.html | 6 +- docs/ja/privacy/index.html | 2 +- docs/ja/threat-model/index.html | 2 +- docs/ko/about/index.html | 2 +- docs/ko/docs/index.html | 4 +- docs/ko/index.html | 6 +- docs/ko/privacy/index.html | 2 +- docs/ko/threat-model/index.html | 2 +- docs/llms-full.txt | 19 +- docs/llms.txt | 2 +- docs/plugins/index.html | 2 +- docs/privacy/index.html | 2 +- docs/pt-PT/about/index.html | 2 +- docs/pt-PT/docs/index.html | 4 +- docs/pt-PT/index.html | 6 +- docs/pt-PT/privacy/index.html | 2 +- docs/pt-PT/threat-model/index.html | 2 +- docs/releases/index.html | 15 +- docs/threat-model/index.html | 2 +- docs/zh-Hans/about/index.html | 2 +- docs/zh-Hans/docs/index.html | 4 +- docs/zh-Hans/index.html | 6 +- docs/zh-Hans/privacy/index.html | 2 +- docs/zh-Hans/threat-model/index.html | 2 +- docs/zh-Hant/about/index.html | 2 +- docs/zh-Hant/docs/index.html | 4 +- docs/zh-Hant/index.html | 6 +- docs/zh-Hant/privacy/index.html | 2 +- docs/zh-Hant/threat-model/index.html | 2 +- project.yml | 4 +- site/pages/releases.html | 13 +- 83 files changed, 951 insertions(+), 574 deletions(-) diff --git a/Flowlight.xcodeproj/project.pbxproj b/Flowlight.xcodeproj/project.pbxproj index d7c6000c..c236b8f4 100644 --- a/Flowlight.xcodeproj/project.pbxproj +++ b/Flowlight.xcodeproj/project.pbxproj @@ -2252,7 +2252,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 73; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -2267,7 +2267,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.16.10; + MARKETING_VERSION = 0.16.11; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2313,7 +2313,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 73; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 38RJUJHKZS; ENABLE_HARDENED_RUNTIME = YES; @@ -2328,7 +2328,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.16.10; + MARKETING_VERSION = 0.16.11; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2393,7 +2393,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 72; + CURRENT_PROJECT_VERSION = 73; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -2414,7 +2414,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.16.10; + MARKETING_VERSION = 0.16.11; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/Flowlight/Analysis/AgentAttributor.swift b/Flowlight/Analysis/AgentAttributor.swift index 726b9023..a46f4b7b 100644 --- a/Flowlight/Analysis/AgentAttributor.swift +++ b/Flowlight/Analysis/AgentAttributor.swift @@ -127,12 +127,18 @@ final class AgentAttributor: @unchecked Sendable { } private func applyLauncher(forAgentPID pid: Int32, to key: inout FlowKey) { - guard pid > 1, - let launcher = AgentLineage.launcher(forAgentPID: pid, lookup: processLookup) - else { return } + guard pid > 1 else { return } + let launchers = AgentLineage.launchers(forAgentPID: pid, lookup: processLookup) + guard let launcher = launchers.first else { return } key.launcherBundleID = launcher.bundleID key.launcherName = launcher.name key.launcherPath = launcher.argv.first + if launchers.count > 1 { + let upstream = launchers[1] + key.upstreamLauncherBundleID = upstream.bundleID + key.upstreamLauncherName = upstream.name + key.upstreamLauncherPath = upstream.argv.first + } } private func context(for key: FlowKey, servers: [MCPServerConfig]) -> AgentContext? { diff --git a/Flowlight/Analysis/MCPAttribution.swift b/Flowlight/Analysis/MCPAttribution.swift index c8a33f6a..a9b578c7 100644 --- a/Flowlight/Analysis/MCPAttribution.swift +++ b/Flowlight/Analysis/MCPAttribution.swift @@ -170,6 +170,13 @@ enum AgentLineage { /// Capture-time launcher evidence for an agent. Historical PIDs are never consulted by visualization queries. static func launcher(forAgentPID pid: Int32, lookup: (Int32) -> ProcessSnapshot?, maxDepth: Int = 8) -> AgentLauncher.Launcher? { + launchers(forAgentPID: pid, lookup: lookup, maxDepth: maxDepth).first + } + + /// Distinct non-shell launchers, nearest first. The first item retains restart semantics; the second preserves + /// the terminal or supervisor that started that launcher for topology evidence. + static func launchers(forAgentPID pid: Int32, lookup: (Int32) -> ProcessSnapshot?, + maxDepth: Int = 8) -> [AgentLauncher.Launcher] { var ancestors: [(pid: Int32, name: String, bundleID: String, argv: [String])] = [] var current = pid for _ in 0.. FlowKey { @@ -71,6 +72,9 @@ enum DemoData { remoteIP: flow.ip, domain: flow.host, port: flow.port, transport: flow.transport, appProtocol: flow.proto, channel: flow.channel, parentAgent: parent?.id, parentAgentName: parent?.name, mcpServer: parent?.mcp, launcherBundleID: launcher?.id, launcherName: launcher?.name, launcherPath: launcher?.path, + upstreamLauncherBundleID: upstreamLauncher?.id, + upstreamLauncherName: upstreamLauncher?.name, + upstreamLauncherPath: upstreamLauncher?.path, routeProxyBundleID: routedThroughFlowlight ? "com.flowlight.app" : nil, routeProxyName: routedThroughFlowlight ? "Flowlight" : nil, routeProxyPath: routedThroughFlowlight ? "/Applications/Flowlight.app" : nil) @@ -111,7 +115,8 @@ enum DemoData { Flow(host: "api.anthropic.com", ip: "198.51.100.80", port: 443, proto: "https", inRate: 700_000, outRate: 1_400_000), Flow(host: "registry.npmjs.org", ip: "198.51.100.81", port: 443, proto: "https", inRate: 300_000, outRate: 10_000), Flow(host: "github.com", ip: "198.51.100.11", port: 22, proto: "ssh", inRate: 50_000, outRate: 30_000), - ], launcher: ("com.mitchellh.ghostty", "Ghostty", "/Applications/Ghostty.app")), + ], launcher: ("herdr", "Herdr", "/Users/demo/.local/bin/herdr"), + upstreamLauncher: ("com.mitchellh.ghostty", "Ghostty", "/Applications/Ghostty.app")), App(bundleID: "com.yetone.magpie", name: "magpie", path: "/Applications/magpie.app", weight: 0.9, flows: [ Flow(host: "api.anthropic.com", ip: "198.51.100.80", port: 443, proto: "https", inRate: 4_000_000, outRate: 8_000_000), Flow(host: "api.openai.com", ip: "198.51.100.90", port: 443, proto: "https", inRate: 2_000_000, outRate: 3_000_000), diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings index 4f6ccf4d..2783f861 100644 --- a/Flowlight/Localization/de.lproj/Localizable.strings +++ b/Flowlight/Localization/de.lproj/Localizable.strings @@ -1869,6 +1869,7 @@ "Fit" = "Einpassen"; "Fit the map in the window" = "Karte an das Fenster anpassen"; "Reset" = "Zurücksetzen"; +"%lld selected" = "%lld ausgewählt"; "Reset zoom and position" = "Zoom und Position zurücksetzen"; "Select to inspect this connection" = "Auswählen, um diese Verbindung zu untersuchen"; "%lld exact destinations" = "%lld exakte Ziele"; diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings index a921c698..9be796c1 100644 --- a/Flowlight/Localization/en.lproj/Localizable.strings +++ b/Flowlight/Localization/en.lproj/Localizable.strings @@ -1868,6 +1868,7 @@ "Fit" = "Fit"; "Fit the map in the window" = "Fit the map in the window"; "Reset" = "Reset"; +"%lld selected" = "%lld selected"; "Reset zoom and position" = "Reset zoom and position"; "Select to inspect this connection" = "Select to inspect this connection"; "%lld exact destinations" = "%lld exact destinations"; diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings index 7cab64c4..90ed07bc 100644 --- a/Flowlight/Localization/es.lproj/Localizable.strings +++ b/Flowlight/Localization/es.lproj/Localizable.strings @@ -1869,6 +1869,7 @@ "Fit" = "Ajustar"; "Fit the map in the window" = "Ajustar el mapa a la ventana"; "Reset" = "Restablecer"; +"%lld selected" = "%lld seleccionados"; "Reset zoom and position" = "Restablecer zoom y posición"; "Select to inspect this connection" = "Selecciona para examinar esta conexión"; "%lld exact destinations" = "%lld destinos exactos"; diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings index 930f8714..838203f6 100644 --- a/Flowlight/Localization/fr.lproj/Localizable.strings +++ b/Flowlight/Localization/fr.lproj/Localizable.strings @@ -1869,6 +1869,7 @@ "Fit" = "Ajuster"; "Fit the map in the window" = "Ajuster la carte à la fenêtre"; "Reset" = "Réinitialiser"; +"%lld selected" = "%lld sélectionnés"; "Reset zoom and position" = "Réinitialiser le zoom et la position"; "Select to inspect this connection" = "Sélectionnez pour examiner cette connexion"; "%lld exact destinations" = "%lld destinations exactes"; diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings index 196f457e..628222be 100644 --- a/Flowlight/Localization/it.lproj/Localizable.strings +++ b/Flowlight/Localization/it.lproj/Localizable.strings @@ -1869,6 +1869,7 @@ "Fit" = "Adatta"; "Fit the map in the window" = "Adatta la mappa alla finestra"; "Reset" = "Ripristina"; +"%lld selected" = "%lld selezionati"; "Reset zoom and position" = "Ripristina zoom e posizione"; "Select to inspect this connection" = "Seleziona per esaminare questa connessione"; "%lld exact destinations" = "%lld destinazioni esatte"; diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings index 4cca0561..a35f2524 100644 --- a/Flowlight/Localization/ja.lproj/Localizable.strings +++ b/Flowlight/Localization/ja.lproj/Localizable.strings @@ -1869,6 +1869,7 @@ "Fit" = "全体表示"; "Fit the map in the window" = "マップ全体をウインドウに表示"; "Reset" = "リセット"; +"%lld selected" = "%lld件を選択"; "Reset zoom and position" = "拡大率と位置をリセット"; "Select to inspect this connection" = "選択してこの接続を調べる"; "%lld exact destinations" = "%lld件の正確な宛先"; diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings index d67454f3..3c07fcb0 100644 --- a/Flowlight/Localization/ko.lproj/Localizable.strings +++ b/Flowlight/Localization/ko.lproj/Localizable.strings @@ -1868,6 +1868,7 @@ "Fit" = "맞춤"; "Fit the map in the window" = "창에 지도 맞추기"; "Reset" = "재설정"; +"%lld selected" = "%lld개 선택됨"; "Reset zoom and position" = "확대/축소 및 위치 재설정"; "Select to inspect this connection" = "선택하여 이 연결 조사"; "%lld exact destinations" = "정확한 대상 %lld개"; diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings index 9c14179a..e215faac 100644 --- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings +++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings @@ -1868,6 +1868,7 @@ "Fit" = "Ajustar"; "Fit the map in the window" = "Ajustar o mapa à janela"; "Reset" = "Repor"; +"%lld selected" = "%lld selecionados"; "Reset zoom and position" = "Repor ampliação e posição"; "Select to inspect this connection" = "Selecione para inspecionar esta ligação"; "%lld exact destinations" = "%lld destinos exatos"; diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings index 5d279922..27c2bb77 100644 --- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings @@ -1868,6 +1868,7 @@ "Fit" = "适应窗口"; "Fit the map in the window" = "使地图适应窗口"; "Reset" = "重置"; +"%lld selected" = "已选择 %lld 项"; "Reset zoom and position" = "重置缩放和位置"; "Select to inspect this connection" = "选择以检查此连接"; "%lld exact destinations" = "%lld 个精确目标"; diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings index f1c940d2..ec26e6d5 100644 --- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings @@ -1868,6 +1868,7 @@ "Fit" = "符合視窗"; "Fit the map in the window" = "使地圖符合視窗"; "Reset" = "重設"; +"%lld selected" = "已選取 %lld 項"; "Reset zoom and position" = "重設縮放和位置"; "Select to inspect this connection" = "選取以檢查此連線"; "%lld exact destinations" = "%lld 個精確目的地"; diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift index 2abc8316..c367bac8 100644 --- a/Flowlight/Storage/TrafficDatabase.swift +++ b/Flowlight/Storage/TrafficDatabase.swift @@ -147,6 +147,9 @@ struct BreakdownRow: Sendable { var launcherBundleID: String = "" var launcherName: String = "" var launcherPath: String = "" + var upstreamLauncherBundleID: String = "" + var upstreamLauncherName: String = "" + var upstreamLauncherPath: String = "" var routeProxyBundleID: String = "" var routeProxyName: String = "" var routeProxyPath: String = "" @@ -377,7 +380,8 @@ final class TrafficDatabase: @unchecked Sendable { let columns = "ts, pid, bundle_id, app_name, app_path, remote_ip, domain, port, transport, protocol, " + "bytes_in, bytes_out, flows, agent_parent, agent_parent_name, mcp_server, " + - "launcher_bundle_id, launcher_name, launcher_path, route_proxy_bundle_id, route_proxy_name, " + + "launcher_bundle_id, launcher_name, launcher_path, upstream_launcher_bundle_id, " + + "upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, " + "route_proxy_path, channel, attribution_exact, attribution_epoch" for (version, real) in realName { for table in Self.tables { @@ -388,12 +392,14 @@ final class TrafficDatabase: @unchecked Sendable { INSERT INTO \(table) (\(columns)) SELECT ts, pid, ?, ?, app_path, remote_ip, domain, port, transport, protocol, bytes_in, bytes_out, flows, agent_parent, agent_parent_name, mcp_server, - launcher_bundle_id, launcher_name, launcher_path, route_proxy_bundle_id, route_proxy_name, + launcher_bundle_id, launcher_name, launcher_path, upstream_launcher_bundle_id, + upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, channel, attribution_exact, attribution_epoch FROM \(table) WHERE bundle_id = ? ON CONFLICT(ts, pid, bundle_id, remote_ip, domain, port, transport, protocol, channel, agent_parent, agent_parent_name, mcp_server, launcher_bundle_id, launcher_name, - launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, + launcher_path, upstream_launcher_bundle_id, upstream_launcher_name, + upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, attribution_exact, attribution_epoch) DO UPDATE SET bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out, @@ -407,12 +413,14 @@ final class TrafficDatabase: @unchecked Sendable { INSERT INTO \(table) (\(columns)) SELECT ts, pid, bundle_id, app_name, app_path, remote_ip, domain, port, transport, protocol, bytes_in, bytes_out, flows, ?, ?, mcp_server, - launcher_bundle_id, launcher_name, launcher_path, route_proxy_bundle_id, route_proxy_name, + launcher_bundle_id, launcher_name, launcher_path, upstream_launcher_bundle_id, + upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, channel, attribution_exact, attribution_epoch FROM \(table) WHERE agent_parent = ? ON CONFLICT(ts, pid, bundle_id, remote_ip, domain, port, transport, protocol, channel, agent_parent, agent_parent_name, mcp_server, launcher_bundle_id, launcher_name, - launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, + launcher_path, upstream_launcher_bundle_id, upstream_launcher_name, + upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, attribution_exact, attribution_epoch) DO UPDATE SET bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out, @@ -517,7 +525,8 @@ final class TrafficDatabase: @unchecked Sendable { // from one executable working for different agents or MCP services cannot merge before visualization. let columns = Set(try conn.query("PRAGMA table_info(\(table))") { $0.text(1) }) for column in ["agent_parent", "agent_parent_name", "mcp_server", "launcher_bundle_id", "launcher_name", - "launcher_path", "route_proxy_bundle_id", "route_proxy_name", "route_proxy_path"] + "launcher_path", "upstream_launcher_bundle_id", "upstream_launcher_name", + "upstream_launcher_path", "route_proxy_bundle_id", "route_proxy_name", "route_proxy_path"] where !columns.contains(column) { try conn.execute("ALTER TABLE \(table) ADD COLUMN \(column) TEXT NOT NULL DEFAULT ''") } @@ -548,9 +557,11 @@ final class TrafficDatabase: @unchecked Sendable { DROP INDEX IF EXISTS \(table)_key3; DROP INDEX IF EXISTS \(table)_key4; DROP INDEX IF EXISTS \(table)_key5; - CREATE UNIQUE INDEX IF NOT EXISTS \(table)_key6 ON \(table)( + DROP INDEX IF EXISTS \(table)_key6; + CREATE UNIQUE INDEX IF NOT EXISTS \(table)_key7 ON \(table)( ts, pid, bundle_id, remote_ip, domain, port, transport, protocol, channel, agent_parent, agent_parent_name, mcp_server, launcher_bundle_id, launcher_name, launcher_path, + upstream_launcher_bundle_id, upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, attribution_exact, attribution_epoch ); """) @@ -777,11 +788,13 @@ final class TrafficDatabase: @unchecked Sendable { let sql = """ INSERT INTO flows_1s (ts, pid, bundle_id, app_name, app_path, remote_ip, domain, port, transport, protocol, bytes_in, bytes_out, flows, agent_parent, agent_parent_name, mcp_server, launcher_bundle_id, launcher_name, launcher_path, + upstream_launcher_bundle_id, upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, channel, attribution_exact, attribution_epoch) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,1) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,1) ON CONFLICT(ts, pid, bundle_id, remote_ip, domain, port, transport, protocol, channel, agent_parent, agent_parent_name, mcp_server, - launcher_bundle_id, launcher_name, launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, + launcher_bundle_id, launcher_name, launcher_path, upstream_launcher_bundle_id, upstream_launcher_name, + upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, attribution_exact, attribution_epoch) DO UPDATE SET bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out, flows = flows + excluded.flows """ @@ -795,6 +808,8 @@ final class TrafficDatabase: @unchecked Sendable { .int(r.counters.bytesOut), .int(r.counters.flows), .text(k.parentAgent ?? ""), .text(k.parentAgentName ?? ""), .text(k.mcpServer ?? ""), .text(k.launcherBundleID ?? ""), .text(k.launcherName ?? ""), .text(k.launcherPath ?? ""), + .text(k.upstreamLauncherBundleID ?? ""), .text(k.upstreamLauncherName ?? ""), + .text(k.upstreamLauncherPath ?? ""), .text(k.routeProxyBundleID ?? ""), .text(k.routeProxyName ?? ""), .text(k.routeProxyPath ?? ""), .text(k.channel.rawValue)]) } @@ -812,11 +827,13 @@ final class TrafficDatabase: @unchecked Sendable { let sql = """ INSERT INTO \(table) (ts, pid, bundle_id, app_name, app_path, remote_ip, domain, port, transport, protocol, bytes_in, bytes_out, flows, agent_parent, agent_parent_name, mcp_server, launcher_bundle_id, launcher_name, launcher_path, + upstream_launcher_bundle_id, upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, channel, attribution_exact, attribution_epoch) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,1) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,1) ON CONFLICT(ts, pid, bundle_id, remote_ip, domain, port, transport, protocol, channel, agent_parent, agent_parent_name, mcp_server, - launcher_bundle_id, launcher_name, launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, + launcher_bundle_id, launcher_name, launcher_path, upstream_launcher_bundle_id, upstream_launcher_name, + upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, attribution_exact, attribution_epoch) DO UPDATE SET bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out, flows = flows + excluded.flows """ @@ -828,6 +845,8 @@ final class TrafficDatabase: @unchecked Sendable { .text(k.appProtocol), .int(r.counters.bytesIn), .int(r.counters.bytesOut), .int(r.counters.flows), .text(k.parentAgent ?? ""), .text(k.parentAgentName ?? ""), .text(k.mcpServer ?? ""), .text(k.launcherBundleID ?? ""), .text(k.launcherName ?? ""), .text(k.launcherPath ?? ""), + .text(k.upstreamLauncherBundleID ?? ""), .text(k.upstreamLauncherName ?? ""), + .text(k.upstreamLauncherPath ?? ""), .text(k.routeProxyBundleID ?? ""), .text(k.routeProxyName ?? ""), .text(k.routeProxyPath ?? ""), .text(k.channel.rawValue)]) } @@ -873,18 +892,22 @@ final class TrafficDatabase: @unchecked Sendable { try conn.run(""" INSERT INTO \(target) (ts, pid, bundle_id, app_name, app_path, remote_ip, domain, port, transport, protocol, bytes_in, bytes_out, flows, agent_parent, agent_parent_name, mcp_server, launcher_bundle_id, launcher_name, launcher_path, + upstream_launcher_bundle_id, upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, channel, attribution_exact, attribution_epoch) SELECT \(bucket), pid, bundle_id, MAX(app_name), MAX(app_path), remote_ip, domain, port, transport, protocol, SUM(bytes_in), SUM(bytes_out), SUM(flows), agent_parent, agent_parent_name, mcp_server, - launcher_bundle_id, launcher_name, launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, + launcher_bundle_id, launcher_name, launcher_path, upstream_launcher_bundle_id, upstream_launcher_name, + upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, channel, attribution_exact, attribution_epoch FROM \(source) WHERE ts >= ? AND ts < ? GROUP BY 1, pid, bundle_id, remote_ip, domain, port, transport, protocol, channel, agent_parent, agent_parent_name, mcp_server, launcher_bundle_id, launcher_name, launcher_path, + upstream_launcher_bundle_id, upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, attribution_exact, attribution_epoch ON CONFLICT(ts, pid, bundle_id, remote_ip, domain, port, transport, protocol, channel, agent_parent, agent_parent_name, mcp_server, - launcher_bundle_id, launcher_name, launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, + launcher_bundle_id, launcher_name, launcher_path, upstream_launcher_bundle_id, upstream_launcher_name, + upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, attribution_exact, attribution_epoch) DO UPDATE SET bytes_in = bytes_in + excluded.bytes_in, bytes_out = bytes_out + excluded.bytes_out, flows = flows + excluded.flows """, [.int(lower), .int(upper)]) @@ -1166,6 +1189,9 @@ final class TrafficDatabase: @unchecked Sendable { CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN launcher_bundle_id ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN launcher_name ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN launcher_path ELSE '' END, + CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN upstream_launcher_bundle_id ELSE '' END, + CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN upstream_launcher_name ELSE '' END, + CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN upstream_launcher_path ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN route_proxy_bundle_id ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN route_proxy_name ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN route_proxy_path ELSE '' END, @@ -1173,16 +1199,19 @@ final class TrafficDatabase: @unchecked Sendable { FROM \(granularity.table) WHERE \(clause) GROUP BY bundle_id, domain, remote_ip, protocol, channel, attribution_exact, attribution_epoch, launcher_bundle_id, launcher_name, launcher_path, + upstream_launcher_bundle_id, upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path ) t LEFT JOIN ip_owners o ON o.ip = t.remote_ip """, values) { row in BreakdownRow(bundleID: row.text(0), appName: row.text(1), appPath: row.text(2), domain: row.text(3), remoteIP: row.text(4), ports: row.text(5), protocols: row.text(6), counters: FlowCounters(bytesIn: row.int(7), bytesOut: row.int(8), flows: row.int(9)), - owner: row.text(17), asn: Int(row.int(18)), + owner: row.text(20), asn: Int(row.int(21)), launcherBundleID: row.text(10), launcherName: row.text(11), launcherPath: row.text(12), - routeProxyBundleID: row.text(13), routeProxyName: row.text(14), routeProxyPath: row.text(15), - channel: NetworkChannel(rawValue: row.text(16)) ?? .ip) + upstreamLauncherBundleID: row.text(13), upstreamLauncherName: row.text(14), + upstreamLauncherPath: row.text(15), + routeProxyBundleID: row.text(16), routeProxyName: row.text(17), routeProxyPath: row.text(18), + channel: NetworkChannel(rawValue: row.text(19)) ?? .ip) } } @@ -1203,6 +1232,9 @@ final class TrafficDatabase: @unchecked Sendable { CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN launcher_bundle_id ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN launcher_name ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN launcher_path ELSE '' END, + CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN upstream_launcher_bundle_id ELSE '' END, + CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN upstream_launcher_name ELSE '' END, + CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN upstream_launcher_path ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN route_proxy_bundle_id ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN route_proxy_name ELSE '' END, CASE WHEN attribution_exact = 1 AND attribution_epoch = 1 THEN route_proxy_path ELSE '' END, @@ -1210,17 +1242,20 @@ final class TrafficDatabase: @unchecked Sendable { FROM \(granularity.table) WHERE \(clause) GROUP BY bundle_id, domain, remote_ip, attribution_exact, attribution_epoch, agent_parent, agent_parent_name, mcp_server, launcher_bundle_id, launcher_name, launcher_path, + upstream_launcher_bundle_id, upstream_launcher_name, upstream_launcher_path, route_proxy_bundle_id, route_proxy_name, route_proxy_path, channel, protocol ) t LEFT JOIN ip_owners o ON o.ip = t.remote_ip """, values) { row in BreakdownRow(bundleID: row.text(0), appName: row.text(1), appPath: row.text(2), domain: row.text(3), remoteIP: row.text(4), ports: row.text(5), protocols: row.text(6), counters: FlowCounters(bytesIn: row.int(7), bytesOut: row.int(8), flows: row.int(9)), - owner: row.text(20), asn: Int(row.int(21)), parentAgent: row.text(10), + owner: row.text(23), asn: Int(row.int(24)), parentAgent: row.text(10), parentAgentName: row.text(11), mcpServer: row.text(12), launcherBundleID: row.text(13), launcherName: row.text(14), launcherPath: row.text(15), - routeProxyBundleID: row.text(16), routeProxyName: row.text(17), routeProxyPath: row.text(18), - channel: NetworkChannel(rawValue: row.text(19)) ?? .ip) + upstreamLauncherBundleID: row.text(16), upstreamLauncherName: row.text(17), + upstreamLauncherPath: row.text(18), + routeProxyBundleID: row.text(19), routeProxyName: row.text(20), routeProxyPath: row.text(21), + channel: NetworkChannel(rawValue: row.text(22)) ?? .ip) } } diff --git a/Flowlight/UI/Visualization/AITrafficGraph.swift b/Flowlight/UI/Visualization/AITrafficGraph.swift index b330f786..493bbad3 100644 --- a/Flowlight/UI/Visualization/AITrafficGraph.swift +++ b/Flowlight/UI/Visualization/AITrafficGraph.swift @@ -113,6 +113,11 @@ enum AITrafficGraphBuilder { var weight: Int64 } + private struct LauncherLink: Hashable { + var source: TrafficGraphNodeID + var target: TrafficGraphNodeID + } + private struct ToolMetadata { var name: String var path: String @@ -637,11 +642,17 @@ enum AITrafficGraphBuilder { )) } var launcherMetadata: [LauncherIdentity: LauncherMetadata] = [:] + var nodeMetadata: [TrafficGraphNodeID: LauncherMetadata] = [:] + var launcherBundleIDs: [TrafficGraphNodeID: String] = [:] var launcherTraffic: [TrafficGraphNodeID: Int64] = [:] + var immediateLauncherIDs: Set = [] + var upstreamLauncherLinks: Set = [] for entry in included where visibleAgents.contains(agentID(entry.agent)) { for row in proofGroups[entry.agent] ?? [] where !row.launcherBundleID.isEmpty { let launcher = LauncherIdentity(agent: entry.agent, bundleID: row.launcherBundleID) let nodeID = launcherID(bundleID: row.launcherBundleID) + immediateLauncherIDs.insert(nodeID) + launcherBundleIDs[nodeID] = row.launcherBundleID launcherTraffic[nodeID, default: 0] += row.counters.total let candidate = LauncherMetadata( name: row.launcherName, @@ -652,15 +663,32 @@ enum AITrafficGraphBuilder { if current == nil || prefersMetadata(candidate, over: current!) { launcherMetadata[launcher] = candidate } + let nodeCurrent = nodeMetadata[nodeID] + if nodeCurrent == nil || prefersMetadata(candidate, over: nodeCurrent!) { + nodeMetadata[nodeID] = candidate + } + + guard !row.upstreamLauncherBundleID.isEmpty else { continue } + let upstreamNodeID = launcherID(bundleID: row.upstreamLauncherBundleID) + guard upstreamNodeID != nodeID else { continue } + launcherBundleIDs[upstreamNodeID] = row.upstreamLauncherBundleID + launcherTraffic[upstreamNodeID, default: 0] += row.counters.total + let upstream = LauncherMetadata( + name: row.upstreamLauncherName, + path: row.upstreamLauncherPath, + weight: row.counters.total + ) + let upstreamCurrent = nodeMetadata[upstreamNodeID] + if upstreamCurrent == nil || prefersMetadata(upstream, over: upstreamCurrent!) { + nodeMetadata[upstreamNodeID] = upstream + } + upstreamLauncherLinks.insert(LauncherLink(source: upstreamNodeID, target: nodeID)) } } - var nodeMetadata: [TrafficGraphNodeID: LauncherMetadata] = [:] - for (launcher, metadata) in launcherMetadata { - let nodeID = launcherID(bundleID: launcher.bundleID) - let current = nodeMetadata[nodeID] - if current == nil || prefersMetadata(metadata, over: current!) { nodeMetadata[nodeID] = metadata } - } let rankedLaunchers = nodeMetadata.sorted { + let leftIsImmediate = immediateLauncherIDs.contains($0.key) + let rightIsImmediate = immediateLauncherIDs.contains($1.key) + if leftIsImmediate != rightIsImmediate { return leftIsImmediate } let leftTraffic = launcherTraffic[$0.key, default: 0] let rightTraffic = launcherTraffic[$1.key, default: 0] if leftTraffic != rightTraffic { return leftTraffic > rightTraffic } @@ -672,21 +700,17 @@ enum AITrafficGraphBuilder { id: launcherNodeID, kind: .launcher, title: metadata.name.isEmpty ? launcherNodeID.rawValue : metadata.name, detail: L("Started agent"), appPath: metadata.path, - filter: TrafficFilter( - bundleID: launcherMetadata.keys.first { - launcherID(bundleID: $0.bundleID) == launcherNodeID - }?.bundleID - ), + filter: TrafficFilter(bundleID: launcherBundleIDs[launcherNodeID]), counters: FlowCounters() ) } - let visibleLauncherEdges = launcherMetadata.keys.sorted { + let visibleImmediateLauncherEdges = launcherMetadata.keys.sorted { let left = launcherID(bundleID: $0.bundleID) let right = launcherID(bundleID: $1.bundleID) if left != right { return left < right } return agentID($0.agent) < agentID($1.agent) }.filter { visibleLaunchers.contains(launcherID(bundleID: $0.bundleID)) } - for launcher in visibleLauncherEdges { + for launcher in visibleImmediateLauncherEdges { let launcherNodeID = launcherID(bundleID: launcher.bundleID) let agentNodeID = agentID(launcher.agent) edges.append(TrafficGraphEdge( @@ -695,6 +719,16 @@ enum AITrafficGraphBuilder { filter: allNodes[agentNodeID]?.filter ?? .none, relationship: .launched )) } + for link in upstreamLauncherLinks.sorted(by: { + if $0.source != $1.source { return $0.source < $1.source } + return $0.target < $1.target + }) where visibleLaunchers.contains(link.source) && visibleLaunchers.contains(link.target) { + edges.append(TrafficGraphEdge( + id: TrafficGraphEdgeID(rawValue: link.source.rawValue + "→launched→" + link.target.rawValue), + source: link.source, target: link.target, counters: FlowCounters(), + filter: allNodes[link.target]?.filter ?? .none, relationship: .launched + )) + } for toolNodeID in visibleTools.sorted() { guard let tool = toolMetadata.keys.first(where: { toolID($0) == toolNodeID }), toolMetadata[tool]?.hasLineage == true @@ -721,8 +755,8 @@ enum AITrafficGraphBuilder { let allDestinationCount = Set(observedCounters.keys.map { destinationID($0.destination) }) .union(blocked.keys.map { destinationID(blockedDestination($0)) }).count let lineageCount = toolMetadata.values.filter(\.hasLineage).count - let allLauncherNodes = Set(launcherMetadata.keys.map { launcherID(bundleID: $0.bundleID) }).count - let allLauncherEdges = launcherMetadata.count + let allLauncherNodes = nodeMetadata.count + let allLauncherEdges = launcherMetadata.count + upstreamLauncherLinks.count let allObservedGatewayNodeIDs = Set(observedCounters.keys.map(\.source).filter { $0.rawValue.hasPrefix("proxy:") }) @@ -770,11 +804,11 @@ enum AITrafficGraphBuilder { evidence.hidden = .none let sourceOptions = TrafficGraphEndpointProjector.sourceOptions( in: evidence, - matchingDestination: endpointQuery.destination + matchingDestinations: endpointQuery.destinations ) let destinationOptions = TrafficGraphEndpointProjector.destinationOptions( in: evidence, - matchingSource: endpointQuery.source + matchingSources: endpointQuery.sources ) let complete = TrafficGraphEndpointProjector.scope(evidence, query: endpointQuery) let map: TrafficGraphSnapshot diff --git a/Flowlight/UI/Visualization/TrafficGraph.swift b/Flowlight/UI/Visualization/TrafficGraph.swift index d29472ef..796b5338 100644 --- a/Flowlight/UI/Visualization/TrafficGraph.swift +++ b/Flowlight/UI/Visualization/TrafficGraph.swift @@ -155,15 +155,20 @@ struct TrafficGraphBuild: Sendable { } struct TrafficGraphEndpointQuery: Equatable, Sendable { - var source: TrafficGraphNodeID? - var destination: TrafficGraphNodeID? + var sources: Set + var destinations: Set init(source: TrafficGraphNodeID? = nil, destination: TrafficGraphNodeID? = nil) { - self.source = source - self.destination = destination + sources = source.map { [$0] } ?? [] + destinations = destination.map { [$0] } ?? [] } - var isFocused: Bool { source != nil || destination != nil } + init(sources: Set, destinations: Set) { + self.sources = sources + self.destinations = destinations + } + + var isFocused: Bool { !sources.isEmpty || !destinations.isEmpty } } struct TrafficGraphEndpointOption: Identifiable, Sendable { @@ -303,33 +308,72 @@ enum TrafficGraphEndpointProjector { } } - static func sourceOptions( + private struct PairEvidence { + var counters = FlowCounters() + var enforcementCount: Int64 = 0 + + static func + (left: PairEvidence, right: PairEvidence) -> PairEvidence { + var counters = left.counters + counters += right.counters + return PairEvidence( + counters: counters, + enforcementCount: left.enforcementCount + right.enforcementCount + ) + } + } + + private static func trafficPairs( in snapshot: TrafficGraphSnapshot, - matchingDestination destination: TrafficGraphNodeID? = nil - ) -> [TrafficGraphEndpointOption] { - let index = Index(snapshot) - var counters: [TrafficGraphNodeID: FlowCounters] = [:] - var enforcementCounts: [TrafficGraphNodeID: Int64] = [:] + index: Index + ) -> [TrafficGraphNodeID: [TrafficGraphNodeID: PairEvidence]] { + var pairs: [TrafficGraphNodeID: [TrafficGraphNodeID: PairEvidence]] = [:] for edge in snapshot.edges where Index.isTraffic(edge) { - if let destination, edge.target != destination { continue } + guard let destination = index.nodesByID[edge.target], + destination.kind == .destination || destination.kind == .service else { continue } for root in index.rootIDs(for: edge) { - if edge.relationship == .observed { - let attributed = index.scopedEdge(edge, sourceIDs: [root])?.counters ?? FlowCounters() - counters[root, default: FlowCounters()] += attributed + guard let scoped = index.scopedEdge(edge, sourceIDs: [root]) else { continue } + var evidence = pairs[root]?[edge.target] ?? PairEvidence() + if scoped.relationship == .observed { + evidence.counters += scoped.counters } else { - if counters[root] == nil { counters[root] = FlowCounters() } - enforcementCounts[root, default: 0] += edge.enforcement?.count ?? 0 + evidence.enforcementCount += scoped.enforcement?.count ?? 0 } + pairs[root, default: [:]][edge.target] = evidence } } - return counters.compactMap { id, counters in - guard let node = index.nodesByID[id] else { return nil } + return pairs + } + + static func sourceOptions( + in snapshot: TrafficGraphSnapshot, + matchingDestination destination: TrafficGraphNodeID? = nil + ) -> [TrafficGraphEndpointOption] { + sourceOptions( + in: snapshot, + matchingDestinations: destination.map { [$0] } ?? [] + ) + } + + static func sourceOptions( + in snapshot: TrafficGraphSnapshot, + matchingDestinations destinations: Set + ) -> [TrafficGraphEndpointOption] { + let index = Index(snapshot) + let pairs = trafficPairs(in: snapshot, index: index) + return pairs.compactMap { id, evidenceByDestination in + guard destinations.isSubset(of: Set(evidenceByDestination.keys)), + let node = index.nodesByID[id] else { return nil } + let evidence = destinations.isEmpty + ? evidenceByDestination.values.reduce(PairEvidence(), +) + : destinations.reduce(PairEvidence()) { result, destination in + result + (evidenceByDestination[destination] ?? PairEvidence()) + } return TrafficGraphEndpointOption( id: id, title: node.title, detail: node.detail, - counters: counters, - enforcementCount: enforcementCounts[id, default: 0] + counters: evidence.counters, + enforcementCount: evidence.enforcementCount ) }.sorted { if $0.counters.total != $1.counters.total { return $0.counters.total > $1.counters.total } @@ -341,30 +385,34 @@ enum TrafficGraphEndpointProjector { static func destinationOptions( in snapshot: TrafficGraphSnapshot, matchingSource source: TrafficGraphNodeID? = nil + ) -> [TrafficGraphEndpointOption] { + destinationOptions( + in: snapshot, + matchingSources: source.map { [$0] } ?? [] + ) + } + + static func destinationOptions( + in snapshot: TrafficGraphSnapshot, + matchingSources sources: Set ) -> [TrafficGraphEndpointOption] { let index = Index(snapshot) - let sourceIDs = source.map { Set([$0]) } ?? [] - var counters: [TrafficGraphNodeID: FlowCounters] = [:] - var enforcementCounts: [TrafficGraphNodeID: Int64] = [:] - for original in snapshot.edges where Index.isTraffic(original) { - guard let edge = index.scopedEdge(original, sourceIDs: sourceIDs) else { continue } - guard let node = index.nodesByID[edge.target], - node.kind == .destination || node.kind == .service else { continue } - if edge.relationship == .observed { - counters[edge.target, default: FlowCounters()] += edge.counters - } else { - if counters[edge.target] == nil { counters[edge.target] = FlowCounters() } - enforcementCounts[edge.target, default: 0] += edge.enforcement?.count ?? 0 + let pairs = trafficPairs(in: snapshot, index: index) + let destinationIDs = pairs.values.reduce(into: Set()) { + $0.formUnion($1.keys) + } + return destinationIDs.compactMap { id in + guard sources.allSatisfy({ pairs[$0]?[id] != nil }), + let node = index.nodesByID[id] else { return nil } + let evidence = (sources.isEmpty ? Set(pairs.keys) : sources).reduce(PairEvidence()) { result, source in + result + (pairs[source]?[id] ?? PairEvidence()) } - } - return counters.compactMap { id, counters in - guard let node = index.nodesByID[id] else { return nil } return TrafficGraphEndpointOption( id: id, title: node.title, detail: node.detail, - counters: counters, - enforcementCount: enforcementCounts[id, default: 0] + counters: evidence.counters, + enforcementCount: evidence.enforcementCount ) }.sorted { if $0.counters.total != $1.counters.total { return $0.counters.total > $1.counters.total } @@ -376,12 +424,12 @@ enum TrafficGraphEndpointProjector { static func scope(_ snapshot: TrafficGraphSnapshot, query: TrafficGraphEndpointQuery) -> TrafficGraphSnapshot { guard query.isFocused else { return snapshot } - return scope(snapshot, sourceIDs: query.source.map { [$0] } ?? [], destination: query.destination) + return scope(snapshot, sourceIDs: query.sources, destinationIDs: query.destinations) } static func defaultMap(_ snapshot: TrafficGraphSnapshot, sourceLimit: Int = 10) -> TrafficGraphSnapshot { let sources = Set(sourceOptions(in: snapshot).prefix(max(0, sourceLimit)).map(\.id)) - let scoped = sources.isEmpty ? snapshot : scope(snapshot, sourceIDs: sources, destination: nil) + let scoped = sources.isEmpty ? snapshot : scope(snapshot, sourceIDs: sources, destinationIDs: []) var map: TrafficGraphSnapshot if scoped.nodes.contains(where: { $0.kind == .agent }) { map = TrafficGraphMapProjector.project( @@ -419,12 +467,12 @@ enum TrafficGraphEndpointProjector { private static func scope( _ snapshot: TrafficGraphSnapshot, sourceIDs: Set, - destination: TrafficGraphNodeID? + destinationIDs: Set ) -> TrafficGraphSnapshot { let index = Index(snapshot) let trafficEdges = snapshot.edges.compactMap { edge -> TrafficGraphEdge? in guard Index.isTraffic(edge) else { return nil } - if let destination, edge.target != destination { return nil } + if !destinationIDs.isEmpty, !destinationIDs.contains(edge.target) { return nil } return index.scopedEdge(edge, sourceIDs: sourceIDs) } var nodeIDs: Set = [] @@ -451,7 +499,7 @@ enum TrafficGraphEndpointProjector { } if trafficEdges.isEmpty { nodeIDs.formUnion(sourceIDs) - if let destination { nodeIDs.insert(destination) } + nodeIDs.formUnion(destinationIDs) } for (id, volume) in supportVolumes { edgesByID[id]?.trafficVolume = volume @@ -1561,11 +1609,11 @@ enum TrafficGraphBuilder { evidence.hidden = .none let sourceOptions = TrafficGraphEndpointProjector.sourceOptions( in: evidence, - matchingDestination: endpointQuery.destination + matchingDestinations: endpointQuery.destinations ) let destinationOptions = TrafficGraphEndpointProjector.destinationOptions( in: evidence, - matchingSource: endpointQuery.source + matchingSources: endpointQuery.sources ) let complete = TrafficGraphEndpointProjector.scope(evidence, query: endpointQuery) let map: TrafficGraphSnapshot diff --git a/Flowlight/UI/Visualization/TrafficGraphLayout.swift b/Flowlight/UI/Visualization/TrafficGraphLayout.swift index 9c350a9e..3055ba3c 100644 --- a/Flowlight/UI/Visualization/TrafficGraphLayout.swift +++ b/Flowlight/UI/Visualization/TrafficGraphLayout.swift @@ -71,6 +71,14 @@ enum TrafficGraphViewportPolicy { static func shouldAutoFit(isUserControlled: Bool) -> Bool { !isUserControlled } + + static func shouldPreserveLayout( + snapshot: TrafficGraphSnapshot, + previous: TrafficGraphLayoutSnapshot + ) -> Bool { + Set(snapshot.nodes.map(\.id)) == Set(previous.nodeFrames.keys) + && Set(snapshot.edges.map(\.id)) == Set(previous.edgeCurves.keys) + } } struct TrafficGraphCurve: Equatable, Sendable { @@ -78,29 +86,12 @@ struct TrafficGraphCurve: Equatable, Sendable { var control1: CGPoint var control2: CGPoint var end: CGPoint - var routingPoints: [CGPoint] = [] var points: [CGPoint] { - routingPoints.count >= 2 ? routingPoints : (0...24).map { point(at: CGFloat($0) / 24) } + (0...24).map { point(at: CGFloat($0) / 24) } } func point(at progress: CGFloat) -> CGPoint { - if routingPoints.count >= 2 { - let segments = zip(routingPoints, routingPoints.dropFirst()).map { ($0, $1, $0.distance(to: $1)) } - let total = segments.reduce(CGFloat(0)) { $0 + $1.2 } - guard total > 0 else { return start } - var remaining = min(1, max(0, progress)) * total - for (from, to, length) in segments { - if remaining <= length { - let fraction = length > 0 ? remaining / length : 0 - return CGPoint(x: from.x + (to.x - from.x) * fraction, - y: from.y + (to.y - from.y) * fraction) - } - remaining -= length - } - return end - } - let t = min(1, max(0, progress)) let inverse = 1 - t let x = inverse * inverse * inverse * start.x @@ -116,29 +107,6 @@ struct TrafficGraphCurve: Equatable, Sendable { /// Offsets a ribbon boundary while keeping its node ports flush with card edges. func offset(by distance: CGFloat) -> TrafficGraphCurve { - if routingPoints.count >= 2 { - let shifted = routingPoints.indices.map { index -> CGPoint in - let previous = routingPoints[max(0, index - 1)] - let next = routingPoints[min(routingPoints.count - 1, index + 1)] - let dx = next.x - previous.x - let dy = next.y - previous.y - let length = hypot(dx, dy) - guard length > 0 else { return routingPoints[index] } - return CGPoint( - x: routingPoints[index].x - dy / length * distance, - y: routingPoints[index].y + dx / length * distance - ) - } - let start = shifted.first ?? self.start - let end = shifted.last ?? self.end - return TrafficGraphCurve( - start: start, - control1: start, - control2: end, - end: end, - routingPoints: shifted - ) - } let shift = CGPoint(x: 0, y: distance) return TrafficGraphCurve( start: CGPoint(x: start.x + shift.x, y: start.y + shift.y), @@ -149,12 +117,6 @@ struct TrafficGraphCurve: Equatable, Sendable { } func hitTestSteps(maximumSegmentLength: CGFloat) -> Int { - guard routingPoints.count < 2 else { - let length = zip(routingPoints, routingPoints.dropFirst()).reduce(CGFloat(0)) { - $0 + $1.0.distance(to: $1.1) - } - return max(1, Int(ceil(length / maximumSegmentLength))) - } let controlPolygon = start.distance(to: control1) + control1.distance(to: control2) + control2.distance(to: end) return max(12, Int(ceil(controlPolygon / maximumSegmentLength))) @@ -366,6 +328,7 @@ enum TrafficGraphLayout { } private enum Lane: Int, CaseIterable { + case upstreamLauncher case launcher case source case tool @@ -494,6 +457,10 @@ enum TrafficGraphLayout { } let trafficRibbons = snapshot.edges.filter(\.isTrafficRibbon) + let routeVariations = Dictionary(uniqueKeysWithValues: trafficRibbons + .sorted { $0.id < $1.id } + .enumerated() + .map { ($0.element.id, $0.offset) }) var sourceSides: [TrafficGraphEdgeID: CardSide] = [:] var targetSides: [TrafficGraphEdgeID: CardSide] = [:] var assignments: [PortKey: [PortAssignment]] = [:] @@ -563,6 +530,7 @@ enum TrafficGraphLayout { } var curves: [TrafficGraphEdgeID: TrafficGraphCurve] = [:] + let separatesRouteCorridors = snapshot.edges.count <= 160 for edge in snapshot.edges.sorted(by: { $0.id < $1.id }) { if Task.isCancelled { return TrafficGraphLayoutSnapshot(nodeFrames: [:], edgeCurves: [:]) @@ -598,8 +566,15 @@ enum TrafficGraphLayout { within: bounds ) } else if edge.isTrafficRibbon { - direct = sankeyCurve(start: start, end: end, avoiding: obstacles, - width: width, within: bounds) + direct = sankeyCurve( + start: start, + end: end, + avoiding: obstacles, + width: width, + within: bounds, + variation: routeVariations[edge.id] ?? 0, + avoidingRoutes: separatesRouteCorridors ? Array(curves.values) : [] + ) } else { direct = parallelCurve(start: start, end: end, fanout: fanoutOffset, routeOffset: fanoutOffset, avoiding: obstacles, @@ -824,15 +799,17 @@ enum TrafficGraphLayout { ) ?? direct } - private static func intersects(_ curve: TrafficGraphCurve, obstacles: [CGRect]) -> Bool { - guard curve.routingPoints.count >= 2 else { - return obstacles.contains { obstacle in - (1..<160).contains { step in - obstacle.contains(curve.point(at: CGFloat(step) / 160)) - } - } - } + private static func controlBounds(_ curve: TrafficGraphCurve) -> CGRect { + let points = [curve.start, curve.control1, curve.control2, curve.end] + let minimumX = points.map(\.x).min() ?? 0 + let maximumX = points.map(\.x).max() ?? 0 + let minimumY = points.map(\.y).min() ?? 0 + let maximumY = points.map(\.y).max() ?? 0 + return CGRect(x: minimumX, y: minimumY, + width: max(0.5, maximumX - minimumX), height: max(0.5, maximumY - minimumY)) + } + private static func intersects(_ curve: TrafficGraphCurve, obstacles: [CGRect]) -> Bool { func segment(_ start: CGPoint, _ end: CGPoint, intersects rect: CGRect) -> Bool { if rect.contains(start) || rect.contains(end) { return true } @@ -856,19 +833,49 @@ enum TrafficGraphLayout { && clip(direction: deltaY, distance: rect.maxY - start.y) } - return obstacles.contains { obstacle in - zip(curve.routingPoints, curve.routingPoints.dropFirst()).contains { start, end in + let bounds = controlBounds(curve) + let relevant = obstacles.filter { bounds.intersects($0) } + guard !relevant.isEmpty else { return false } + let points = (0...160).map { curve.point(at: CGFloat($0) / 160) } + return relevant.contains { obstacle in + zip(points, points.dropFirst()).contains { start, end in segment(start, end, intersects: obstacle) } } } + private static func corridorOverlap( + _ candidate: TrafficGraphCurve, + with routes: [TrafficGraphCurve] + ) -> Int { + let candidateBounds = controlBounds(candidate).insetBy(dx: -4, dy: -4) + return routes.reduce(0) { largest, route in + guard candidateBounds.intersects(controlBounds(route)) else { return largest } + let closeSamples = (15..<85).reduce(0) { count, step in + let progress = CGFloat(step) / 100 + let left = candidate.point(at: progress) + let right = route.point(at: progress) + return count + (hypot(left.x - right.x, left.y - right.y) < 4 ? 1 : 0) + } + return max(largest, closeSamples) + } + } + + private static func sharesCorridor( + _ candidate: TrafficGraphCurve, + with routes: [TrafficGraphCurve] + ) -> Bool { + corridorOverlap(candidate, with: routes) >= 8 + } + private static func sankeyCurve( start: CGPoint, end: CGPoint, avoiding obstacles: [CGRect] = [], width: CGFloat = 0, - within bounds: CGRect + within bounds: CGRect, + variation: Int = 0, + avoidingRoutes: [TrafficGraphCurve] = [] ) -> TrafficGraphCurve { let controlOffset = max(24, abs(end.x - start.x) * 0.42) let direction: CGFloat = end.x >= start.x ? 1 : -1 @@ -880,7 +887,12 @@ enum TrafficGraphLayout { ) let clearance = 10 + max(0.5, width / 2) let padded = obstacles.map { $0.insetBy(dx: -clearance, dy: -clearance) } - guard intersects(direct, obstacles: padded) else { return direct } + let directBounds = controlBounds(direct) + let routingObstacles = padded.count > 160 + ? padded.filter { directBounds.intersects($0) } + : padded + guard intersects(direct, obstacles: padded) + || sharesCorridor(direct, with: avoidingRoutes) else { return direct } let above = (padded.map(\.minY).min() ?? min(start.y, end.y)) - 16 let below = (padded.map(\.maxY).max() ?? max(start.y, end.y)) + 16 @@ -890,9 +902,12 @@ enum TrafficGraphLayout { start: start, end: end, obstacles: padded, + routingObstacles: routingObstacles, preferAbove: preferAbove, width: width, - within: bounds + within: bounds, + variation: variation, + avoidingRoutes: avoidingRoutes ) ?? direct } @@ -900,9 +915,12 @@ enum TrafficGraphLayout { start: CGPoint, end: CGPoint, obstacles: [CGRect], + routingObstacles: [CGRect]? = nil, preferAbove: Bool, width: CGFloat, - within bounds: CGRect + within bounds: CGRect, + variation: Int = 0, + avoidingRoutes: [TrafficGraphCurve] = [] ) -> TrafficGraphCurve? { let halfWidth = max(0.5, width / 2) let minimumY = bounds.minY + halfWidth + 2 @@ -924,206 +942,126 @@ enum TrafficGraphLayout { ) } - func candidates(from origin: CGFloat, toward limit: CGFloat, step: CGFloat) -> [CGFloat] { - var values: [CGFloat] = [] - var value = min(maximumY, max(minimumY, origin)) - while step < 0 ? value >= limit : value <= limit { - values.append(value) - value += step - } - if values.last != limit { values.append(limit) } - return values + func clampedY(_ value: CGFloat) -> CGFloat { + min(maximumY, max(minimumY, value)) } - let ascending = candidates(from: minimumY, toward: maximumY, step: 24) - let ordered = preferAbove ? ascending : Array(ascending.reversed()) + let bandObstacles = routingObstacles ?? obstacles + let upperBands = Set([minimumY] + bandObstacles.map { clampedY($0.minY - 4) }) + let lowerBands = Set([maximumY] + bandObstacles.map { clampedY($0.maxY + 4) }) + // Search the closest useful corridor first. Trying the outer canvas edge first created a technically valid + // but visually foreign U-turn: a route plunged to a shared bottom baseline even when a nearer card edge + // could clear the same obstacle. Side preference only breaks equal-distance ties. + let rankedBands = Array(upperBands.union(lowerBands)).sorted { left, right in + let leftDistance = abs(start.y - left) + abs(end.y - left) + let rightDistance = abs(start.y - right) + abs(end.y - right) + if leftDistance != rightDistance { return leftDistance < rightDistance } + let leftPreferred = preferAbove ? upperBands.contains(left) : lowerBands.contains(left) + let rightPreferred = preferAbove ? upperBands.contains(right) : lowerBands.contains(right) + if leftPreferred != rightPreferred { return leftPreferred } + return left < right + } + let denseSearch = obstacles.count > 160 + let ordered = denseSearch ? Array(rankedBands.prefix(4)) : rankedBands let controlProfiles: [(CGFloat, CGFloat)] = [ (0.42, 0.58), + (0.30, 0.45), + (0.55, 0.70), (0.08, 0.28), (0.72, 0.92), (0.08, 0.55), (0.45, 0.92), (0.12, 0.88), - (0.30, 0.45), - (0.55, 0.70), - (0.42, 0.49) + (0.42, 0.49), + (0.02, 0.98) ] - for routeY in ordered { - for profile in controlProfiles { - // Independent controls let a single smooth S-bend turn in the open space between - // columns while retaining a horizontal departure or arrival beside crowded cards. - let candidates = [ - curve(control1Fraction: profile.0, control2Fraction: profile.1, - control1Y: start.y, control2Y: routeY), - curve(control1Fraction: profile.0, control2Fraction: profile.1, - control1Y: routeY, control2Y: end.y), - curve(control1Fraction: profile.0, control2Fraction: profile.1, - control1Y: routeY, - control2Y: routeY + (end.y - routeY) * 0.25), - curve(control1Fraction: profile.0, control2Fraction: profile.1, - control1Y: routeY, - control2Y: routeY + (end.y - routeY) * 0.40), - curve(control1Fraction: profile.0, control2Fraction: profile.1, - control1Y: start.y + (routeY - start.y) * 0.75, - control2Y: routeY), - curve(control1Fraction: profile.0, control2Fraction: profile.1, - control1Y: (start.y + routeY) / 2, - control2Y: (end.y + routeY) / 2), - curve(control1Fraction: profile.0, control2Fraction: profile.1, - control1Y: routeY, control2Y: routeY) - ] - if let clear = candidates.first(where: { !intersects($0, obstacles: obstacles) }) { - return clear - } + let smoothProfiles = Array(controlProfiles.prefix(3)) + let profileShift = max(0, variation) % smoothProfiles.count + let orderedSmoothProfiles = Array(smoothProfiles.dropFirst(profileShift)) + + Array(smoothProfiles.prefix(profileShift)) + let orderedProfiles = denseSearch + ? Array(orderedSmoothProfiles.prefix(1)) + : orderedSmoothProfiles + Array(controlProfiles.dropFirst(3)) + let controlFactors: [CGFloat] = denseSearch ? [1, 2.6] : [1, 4 / 3, 2, 2.6, 3.2] + func staysInsideCanvas(_ candidate: TrafficGraphCurve) -> Bool { + (0...64).allSatisfy { step in + let point = candidate.point(at: CGFloat(step) / 64) + return point.y >= minimumY && point.y <= maximumY } } - func sampledSegment( - start: CGPoint, - control1: CGPoint, - control2: CGPoint, - end: CGPoint, - includingStart: Bool - ) -> [CGPoint] { - let segment = TrafficGraphCurve( - start: start, - control1: control1, - control2: control2, - end: end - ) - let first = includingStart ? 0 : 1 - return (first...24).map { segment.point(at: CGFloat($0) / 24) } - } - - func smoothDetour( - routeY: CGFloat, - departure: CGFloat, - arrival: CGFloat - ) -> TrafficGraphCurve { - let centerY = (start.y + end.y) / 2 - let shoulderDirection: CGFloat = routeY <= centerY ? 1 : -1 - let shoulderOffset = min(24, max(8, abs(centerY - routeY) * 0.08)) - let shoulderY = min(maximumY, max(minimumY, routeY + shoulderDirection * shoulderOffset)) - let departureSpan = span * departure - let arrivalSpan = span * (1 - arrival) - let departurePoint = CGPoint( - x: start.x + departureSpan * direction, - y: shoulderY - ) - let arrivalPoint = CGPoint( - x: start.x + span * arrival * direction, - y: shoulderY - ) - let apex = CGPoint( - x: start.x + span * ((departure + arrival) / 2) * direction, - y: routeY - ) - let firstHandle = min(departureSpan * 0.45, max(12, departureSpan - 1)) - let lastHandle = min(arrivalSpan * 0.45, max(12, arrivalSpan - 1)) - let middleHandle = abs(apex.x - departurePoint.x) * 0.42 - var points = sampledSegment( - start: start, - control1: CGPoint(x: start.x + firstHandle * direction, y: start.y), - control2: CGPoint(x: departurePoint.x - firstHandle * 0.35 * direction, y: shoulderY), - end: departurePoint, - includingStart: true - ) - points += sampledSegment( - start: departurePoint, - control1: CGPoint(x: departurePoint.x + middleHandle * direction, y: shoulderY), - control2: CGPoint(x: apex.x - middleHandle * direction, y: routeY), - end: apex, - includingStart: false - ) - points += sampledSegment( - start: apex, - control1: CGPoint(x: apex.x + middleHandle * direction, y: routeY), - control2: CGPoint(x: arrivalPoint.x - middleHandle * direction, y: shoulderY), - end: arrivalPoint, - includingStart: false - ) - points += sampledSegment( - start: arrivalPoint, - control1: CGPoint(x: arrivalPoint.x + lastHandle * 0.35 * direction, y: shoulderY), - control2: CGPoint(x: end.x - lastHandle * direction, y: end.y), - end: end, - includingStart: false - ) - return TrafficGraphCurve( - start: start, - control1: start, - control2: end, - end: end, - routingPoints: points - ) - } - - func fractions(for obstacle: CGRect) -> ClosedRange { - let first = (obstacle.minX - start.x) * direction / max(span, 1) - let second = (obstacle.maxX - start.x) * direction / max(span, 1) - return min(first, second)...max(first, second) - } - - let interiorObstacles = obstacles.filter { obstacle in - let range = fractions(for: obstacle) - return range.lowerBound > 0.001 && range.upperBound < 0.999 - } - let blockers = interiorObstacles.isEmpty ? obstacles : interiorObstacles - let blockerFractions = blockers.map(fractions) - let firstBlocker = blockerFractions.map(\.lowerBound).min() ?? 0.10 - let lastBlocker = blockerFractions.map(\.upperBound).max() ?? 0.90 - let departure = max(0.02, min(0.45, firstBlocker - 0.025)) - let arrival = min(0.98, max(0.55, lastBlocker + 0.025)) - let departures = [departure, max(0.02, departure - 0.04), max(0.02, departure - 0.08), 0.02] - let arrivals = [arrival, min(0.98, arrival + 0.04), min(0.98, arrival + 0.08), 0.98] - let centerY = (start.y + end.y) / 2 - let localBands = blockers.flatMap { obstacle in - [ - max(minimumY, obstacle.minY - 4), - min(maximumY, obstacle.maxY + 4) - ] - } - let routeBands = Array(Set(localBands + [minimumY, maximumY])).sorted { left, right in - let leftCost = abs(start.y - left) + abs(end.y - left) - let rightCost = abs(start.y - right) + abs(end.y - right) - if leftCost == rightCost { - return preferAbove ? left < right : left > right - } - if abs(leftCost - rightCost) < 0.001 { - return abs(left - centerY) < abs(right - centerY) - } - return leftCost < rightCost - } - for routeY in routeBands { - for departure in departures { - for arrival in arrivals where departure < arrival { - let candidate = smoothDetour( - routeY: routeY, - departure: departure, - arrival: arrival - ) - if !intersects(candidate, obstacles: obstacles) { return candidate } + var obstacleClearFallback: TrafficGraphCurve? + var fallbackOverlap = Int.max + for routeY in ordered { + if Task.isCancelled { return nil } + for profile in orderedProfiles { + if Task.isCancelled { return nil } + // A cubic reaches only partway toward its controls. Extrapolate the controls beyond the desired + // corridor so the rendered curve itself can make one restrained arc around a full card column. + var candidates: [TrafficGraphCurve] = [] + if denseSearch { + for factor in controlFactors { + let firstY = start.y + (routeY - start.y) * factor + let secondY = end.y + (routeY - end.y) * factor + candidates.append(curve( + control1Fraction: profile.0, + control2Fraction: profile.1, + control1Y: firstY, + control2Y: secondY + )) + candidates.append(curve( + control1Fraction: profile.0, + control2Fraction: profile.1, + control1Y: firstY, + control2Y: end.y + )) + candidates.append(curve( + control1Fraction: profile.0, + control2Fraction: profile.1, + control1Y: start.y, + control2Y: secondY + )) + } + } else { + for firstFactor in controlFactors { + let firstY = start.y + (routeY - start.y) * firstFactor + candidates.append(curve( + control1Fraction: profile.0, + control2Fraction: profile.1, + control1Y: firstY, + control2Y: end.y + )) + for secondFactor in controlFactors { + candidates.append(curve( + control1Fraction: profile.0, + control2Fraction: profile.1, + control1Y: firstY, + control2Y: end.y + (routeY - end.y) * secondFactor + )) + } + } + for secondFactor in controlFactors { + candidates.append(curve( + control1Fraction: profile.0, + control2Fraction: profile.1, + control1Y: start.y, + control2Y: end.y + (routeY - end.y) * secondFactor + )) + } } - } - } - - let outerBands = preferAbove - ? [minimumY, maximumY] - : [maximumY, minimumY] - let outerDepartures: [CGFloat] = [0.02, 0.04, 0.06, 0.08, 0.12, 0.18, 0.24, 0.30] - let outerArrivals: [CGFloat] = [0.98, 0.96, 0.94, 0.92, 0.88, 0.82, 0.76, 0.70] - for routeY in outerBands { - for departure in outerDepartures { - for arrival in outerArrivals where departure < arrival { - let candidate = smoothDetour( - routeY: routeY, - departure: departure, - arrival: arrival - ) - if !intersects(candidate, obstacles: obstacles) { return candidate } + for candidate in candidates { + if Task.isCancelled { return nil } + guard staysInsideCanvas(candidate), + !intersects(candidate, obstacles: obstacles) else { continue } + let overlap = corridorOverlap(candidate, with: avoidingRoutes) + if overlap < fallbackOverlap { + obstacleClearFallback = candidate + fallbackOverlap = overlap + } + if overlap < 8 { return candidate } } } } - return nil + return obstacleClearFallback } private static func orderedNodes(snapshot: TrafficGraphSnapshot, @@ -1175,10 +1113,17 @@ enum TrafficGraphLayout { private static func lanes(snapshot: TrafficGraphSnapshot) -> [TrafficGraphNodeID: Lane] { let gatewayTargets = Set(snapshot.edges.filter { $0.relationship == .configuredGateway }.map(\.target)) let inspectionTargets = Set(snapshot.edges.filter { $0.relationship == .routedThrough }.map(\.target)) + let nodesByID = Dictionary(uniqueKeysWithValues: snapshot.nodes.map { ($0.id, $0) }) + let upstreamLauncherIDs = Set(snapshot.edges.compactMap { edge -> TrafficGraphNodeID? in + guard edge.relationship == .launched, + nodesByID[edge.target]?.kind == .launcher else { return nil } + return edge.source + }) return Dictionary(uniqueKeysWithValues: snapshot.nodes.map { node in let lane: Lane switch node.kind { - case .launcher: lane = .launcher + case .launcher: + lane = upstreamLauncherIDs.contains(node.id) ? .upstreamLauncher : .launcher case .app, .agent: lane = .source case .tool, .mcp: lane = .tool case .proxy: @@ -1192,7 +1137,7 @@ enum TrafficGraphLayout { private static func size(for lane: Lane) -> CGSize { switch lane { - case .launcher, .source: return appSize + case .upstreamLauncher, .launcher, .source: return appSize case .tool, .gateway, .inspectionProxy: return intermediarySize case .destination: return destinationSize } diff --git a/Flowlight/UI/VisualizeView.swift b/Flowlight/UI/VisualizeView.swift index 84e3029c..946026ed 100644 --- a/Flowlight/UI/VisualizeView.swift +++ b/Flowlight/UI/VisualizeView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI enum VisualizeSelectionPolicy { @@ -36,11 +37,10 @@ enum VisualizeSelectionPolicy { } static func validEndpointSelection( - _ selection: TrafficGraphNodeID?, + _ selection: Set, options: [TrafficGraphEndpointOption] - ) -> TrafficGraphNodeID? { - guard let selection else { return nil } - return options.contains { $0.id == selection } ? selection : nil + ) -> Set { + selection.intersection(options.map(\.id)) } static func revealedNodeFocus( @@ -79,6 +79,9 @@ enum VisualizeEvidenceScope { row.launcherBundleID, row.launcherName, row.launcherPath, + row.upstreamLauncherBundleID, + row.upstreamLauncherName, + row.upstreamLauncherPath, row.routeProxyBundleID, row.routeProxyName, row.routeProxyPath @@ -91,6 +94,7 @@ enum VisualizeEvidenceScope { ) || focus.matches(bundleID: row.parentAgent, domain: "", remoteIP: "") || focus.matches(bundleID: row.launcherBundleID, domain: "", remoteIP: "") + || focus.matches(bundleID: row.upstreamLauncherBundleID, domain: "", remoteIP: "") || focus.matches(bundleID: row.routeProxyBundleID, domain: "", remoteIP: "") return matchesQuery && matchesFocus } @@ -240,8 +244,8 @@ struct VisualizeView: View { @State private var selectedEdge: TrafficGraphEdgeID? @State private var sourceOptions: [TrafficGraphEndpointOption] = [] @State private var destinationOptions: [TrafficGraphEndpointOption] = [] - @State private var selectedSource: TrafficGraphNodeID? - @State private var selectedDestination: TrafficGraphNodeID? + @State private var selectedSources: Set = [] + @State private var selectedDestinations: Set = [] @State private var query = "" @State private var loading = false @State private var loadID = UUID() @@ -307,8 +311,8 @@ struct VisualizeView: View { } .task(id: RebuildKey(loadID: loadID, query: query, mode: mode, focus: focus.scope, gatewayVersion: gatewayVersion, - selectedSource: selectedSource, - selectedDestination: selectedDestination, + selectedSources: selectedSources, + selectedDestinations: selectedDestinations, expandedGroupIDs: expandedGroupIDs, expandedEdgeGroupIDs: expandedEdgeGroupIDs)) { await rebuild() } .onChange(of: mode) { _, _ in @@ -322,8 +326,8 @@ struct VisualizeView: View { selectedEdge = nil sourceOptions = [] destinationOptions = [] - selectedSource = nil - selectedDestination = nil + selectedSources = [] + selectedDestinations = [] loadID = UUID() } .onChange(of: granularity) { _, _ in @@ -344,8 +348,8 @@ struct VisualizeView: View { selectedNode = nil selectedEdge = nil } - .onChange(of: selectedSource) { _, _ in resetEndpointContext() } - .onChange(of: selectedDestination) { _, _ in resetEndpointContext() } + .onChange(of: selectedSources) { _, _ in resetEndpointContext() } + .onChange(of: selectedDestinations) { _, _ in resetEndpointContext() } .onChange(of: presentation) { _, current in let omitsSelection = switch current { case .map: @@ -428,7 +432,7 @@ struct VisualizeView: View { searchPrompt: mode == .traffic ? L("Search applications") : L("Search AI agents"), systemImage: mode == .traffic ? "app" : "sparkles", options: sourceOptions, - selection: $selectedSource + selection: $selectedSources ) Image(systemName: "arrow.right") .font(.caption.bold()) @@ -440,19 +444,19 @@ struct VisualizeView: View { searchPrompt: L("Search destinations"), systemImage: "globe", options: destinationOptions, - selection: $selectedDestination + selection: $selectedDestinations ) - Text(selectedSource == nil && selectedDestination == nil + Text(selectedSources.isEmpty && selectedDestinations.isEmpty ? L("Map shows the top 10 sources by traffic. List keeps every exact relationship.") : L("Focused scope shows every connected relationship; large neighborhoods group progressively.")) .font(.caption) .foregroundStyle(FL.muted) .lineLimit(2) Spacer(minLength: 0) - if selectedSource != nil || selectedDestination != nil { + if !selectedSources.isEmpty || !selectedDestinations.isEmpty { Button { - selectedSource = nil - selectedDestination = nil + selectedSources = [] + selectedDestinations = [] } label: { Label(L("Reset"), systemImage: "arrow.counterclockwise") } @@ -569,8 +573,8 @@ struct VisualizeView: View { let expandedGroupIDs = expandedGroupIDs let expandedEdgeGroupIDs = expandedEdgeGroupIDs let endpointQuery = TrafficGraphEndpointQuery( - source: selectedSource, - destination: selectedDestination + sources: selectedSources, + destinations: selectedDestinations ) let query = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let built = await Task.detached(priority: .userInitiated) { @@ -624,19 +628,19 @@ struct VisualizeView: View { } }.value guard !Task.isCancelled else { return } - let validSource = VisualizeSelectionPolicy.validEndpointSelection( - endpointQuery.source, + let validSources = VisualizeSelectionPolicy.validEndpointSelection( + endpointQuery.sources, options: built.sourceOptions ) - let validDestination = VisualizeSelectionPolicy.validEndpointSelection( - endpointQuery.destination, + let validDestinations = VisualizeSelectionPolicy.validEndpointSelection( + endpointQuery.destinations, options: built.destinationOptions ) - if validSource != endpointQuery.source || validDestination != endpointQuery.destination { + if validSources != endpointQuery.sources || validDestinations != endpointQuery.destinations { sourceOptions = built.sourceOptions destinationOptions = built.destinationOptions - selectedSource = validSource - selectedDestination = validDestination + selectedSources = validSources + selectedDestinations = validDestinations return } completeSnapshot = built.complete @@ -691,8 +695,8 @@ struct VisualizeView: View { var mode: Mode var focus: FocusScope var gatewayVersion: String - var selectedSource: TrafficGraphNodeID? - var selectedDestination: TrafficGraphNodeID? + var selectedSources: Set + var selectedDestinations: Set var expandedGroupIDs: Set var expandedEdgeGroupIDs: Set } @@ -704,12 +708,17 @@ private struct TrafficGraphEndpointPicker: View { var searchPrompt: String var systemImage: String var options: [TrafficGraphEndpointOption] - @Binding var selection: TrafficGraphNodeID? + @Binding var selection: Set @State private var isPresented = false @State private var query = "" - private var selectedOption: TrafficGraphEndpointOption? { - options.first { $0.id == selection } + private var selectionSummary: String { + guard !selection.isEmpty else { return anyTitle } + if selection.count == 1, + let option = options.first(where: { selection.contains($0.id) }) { + return option.title + } + return L("%lld selected", Int64(selection.count)) } private var filteredOptions: [TrafficGraphEndpointOption] { @@ -733,7 +742,7 @@ private struct TrafficGraphEndpointPicker: View { Text(label) .font(.caption2) .foregroundStyle(FL.muted) - Text(selectedOption?.title ?? anyTitle) + Text(selectionSummary) .font(.callout.weight(.medium)) .foregroundStyle(FL.ink) .lineLimit(1) @@ -753,7 +762,7 @@ private struct TrafficGraphEndpointPicker: View { } } .buttonStyle(.plain) - .accessibilityLabel("\(label): \(selectedOption?.title ?? anyTitle)") + .accessibilityLabel("\(label): \(selectionSummary)") .popover(isPresented: $isPresented, arrowEdge: .bottom) { VStack(spacing: 0) { TextField(searchPrompt, text: $query) @@ -787,13 +796,22 @@ private struct TrafficGraphEndpointPicker: View { detail: String?, counters: FlowCounters? ) -> some View { - Button { - selection = id - isPresented = false + let isSelected = id.map(selection.contains) ?? selection.isEmpty + return Button { + guard let id else { + selection = [] + isPresented = false + return + } + if selection.contains(id) { + selection.remove(id) + } else { + selection.insert(id) + } } label: { HStack(spacing: Spacing.s) { - Image(systemName: selection == id ? "checkmark.circle.fill" : "circle") - .foregroundStyle(selection == id ? FL.accent : FL.muted) + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? FL.accent : FL.muted) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(title).foregroundStyle(FL.ink).lineLimit(1) @@ -813,7 +831,7 @@ private struct TrafficGraphEndpointPicker: View { .padding(.vertical, 7) } .buttonStyle(.plain) - .accessibilityAddTraits(selection == id ? .isSelected : []) + .accessibilityAddTraits(isSelected ? .isSelected : []) } } @@ -829,6 +847,37 @@ private enum GraphSelection { case edge(TrafficGraphEdge, source: TrafficGraphNode?, target: TrafficGraphNode?) } +private struct TrafficGraphLoadingView: View { + var reduceMotion: Bool + @State private var flipped = false + + var body: some View { + VStack(spacing: Spacing.m) { + Image(nsImage: NSApplication.shared.applicationIconImage) + .resizable() + .scaledToFit() + .frame(width: 52, height: 52) + .rotation3DEffect( + .degrees(flipped && !reduceMotion ? 360 : 0), + axis: (x: 0, y: 1, z: 0), + perspective: 0.55 + ) + .animation( + reduceMotion ? nil : .easeInOut(duration: 1.05).repeatForever(autoreverses: false), + value: flipped + ) + Text(L("Loading visualization…")) + .font(.callout.weight(.medium)) + .foregroundStyle(FL.muted) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(FL.ground) + .onAppear { flipped = true } + .accessibilityElement(children: .combine) + .accessibilityLabel(L("Loading visualization…")) + } +} + private struct TrafficGraphCanvas: View { var snapshot: TrafficGraphSnapshot @Binding var selectedNode: TrafficGraphNodeID? @@ -838,6 +887,7 @@ private struct TrafficGraphCanvas: View { @State private var hoveredNode: TrafficGraphNodeID? @State private var hoveredEdge: TrafficGraphEdgeID? @State private var cachedLayout = TrafficGraphLayoutSnapshot(nodeFrames: [:], edgeCurves: [:]) + @State private var renderedLayoutKey: TrafficGraphLayoutKey? @State private var viewport = TrafficGraphViewport() @State private var panStart: TrafficGraphViewport? @State private var zoomStart: TrafficGraphViewport? @@ -853,6 +903,7 @@ private struct TrafficGraphCanvas: View { let graphBounds = CGRect(origin: .zero, size: graphSize) let layoutKey = TrafficGraphLayoutKey(snapshot: snapshot, size: graphSize) let layout = cachedLayout.updatingTrafficWidths(for: snapshot) + let layoutIsReady = renderedLayoutKey == layoutKey && !layout.nodeFrames.isEmpty let screenBounds = CGRect(origin: .zero, size: geometry.size) let visible = layout.visibleRegion( in: viewport.visibleGraphRect(in: screenBounds), @@ -860,14 +911,17 @@ private struct TrafficGraphCanvas: View { ) ZStack(alignment: .topLeading) { Color.clear - if layout.nodeFrames.isEmpty, !snapshot.nodes.isEmpty { - ProgressView(L("Loading visualization…")) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } graphContent(layout, visible: visible, size: graphSize) .scaleEffect(viewport.scale, anchor: .topLeading) .offset(viewport.offset) - viewportControls(layout: layout, in: screenBounds) + .opacity(layoutIsReady ? 1 : 0) + .allowsHitTesting(layoutIsReady) + if layoutIsReady { + viewportControls(layout: layout, in: screenBounds) + } else { + TrafficGraphLoadingView(reduceMotion: reduceMotion) + .transition(.opacity) + } } .clipped() .contentShape(Rectangle()) @@ -891,7 +945,12 @@ private struct TrafficGraphCanvas: View { .gesture(panGesture) .simultaneousGesture(zoomGesture(in: geometry.size)) .task(id: layoutKey) { - let previous = cachedLayout.nodeFrames.isEmpty ? nil : cachedLayout + let canPreserve = TrafficGraphViewportPolicy.shouldPreserveLayout( + snapshot: snapshot, + previous: cachedLayout + ) + let previous = canPreserve ? cachedLayout : nil + if !canPreserve { viewportIsUserControlled = false } let worker = Task.detached(priority: .userInitiated) { TrafficGraphLayout.layout( snapshot: snapshot, @@ -910,6 +969,7 @@ private struct TrafficGraphCanvas: View { ) { viewport.fit(content: nextLayout.contentBounds, in: screenBounds) } + renderedLayoutKey = layoutKey } .accessibilityElement(children: .contain) .accessibilityLabel(L("Traffic relationship map")) @@ -1053,45 +1113,21 @@ private struct TrafficGraphCanvas: View { let counters = edge.displayedCounters let width = layout.edgeWidths[edge.id] ?? 1 let ribbonColor = FL.muted.opacity(emphasized ? 0.44 : related ? 0.20 : 0.05) - if curve.routingPoints.count >= 2 { - context.stroke( - path(curve), - with: .color(ribbonColor), - style: StrokeStyle(lineWidth: width, lineCap: .round, lineJoin: .round) - ) - } else { - context.fill(ribbonPath(curve, width: width), with: .color(ribbonColor)) - } + context.fill(ribbonPath(curve, width: width), with: .color(ribbonColor)) guard emphasized, counters.total > 0 else { continue } let lanes = TrafficGraphFlowDash.lanes(counters: counters, totalWidth: width) if lanes.received.width > 0 { - if curve.routingPoints.count >= 2 { - context.stroke( - path(curve.offset(by: lanes.received.offset)), - with: .color(FL.graphReceived.opacity(0.88)), - style: StrokeStyle(lineWidth: lanes.received.width, lineCap: .round, lineJoin: .round) - ) - } else { - context.fill( - ribbonPath(curve, width: lanes.received.width, offset: lanes.received.offset), - with: .color(FL.graphReceived.opacity(0.88)) - ) - } + context.fill( + ribbonPath(curve, width: lanes.received.width, offset: lanes.received.offset), + with: .color(FL.graphReceived.opacity(0.88)) + ) } if lanes.sent.width > 0 { - if curve.routingPoints.count >= 2 { - context.stroke( - path(curve.offset(by: lanes.sent.offset)), - with: .color(FL.graphSent.opacity(0.88)), - style: StrokeStyle(lineWidth: lanes.sent.width, lineCap: .round, lineJoin: .round) - ) - } else { - context.fill( - ribbonPath(curve, width: lanes.sent.width, offset: lanes.sent.offset), - with: .color(FL.graphSent.opacity(0.88)) - ) - } + context.fill( + ribbonPath(curve, width: lanes.sent.width, offset: lanes.sent.offset), + with: .color(FL.graphSent.opacity(0.88)) + ) } } } @@ -1254,40 +1290,8 @@ private struct TrafficGraphCanvas: View { private func path(_ curve: TrafficGraphCurve) -> Path { Path { path in path.move(to: curve.start) - if curve.routingPoints.count >= 2 { - addRoundedRoute(curve.routingPoints, to: &path) - } else { - path.addCurve(to: curve.end, control1: curve.control1, control2: curve.control2) - } - } - } - - private func addRoundedRoute(_ points: [CGPoint], to path: inout Path) { - guard points.count > 2 else { - path.addLines(Array(points.dropFirst())) - return - } - let radius: CGFloat = 8 - for index in 1..<(points.count - 1) { - let previous = points[index - 1] - let corner = points[index] - let next = points[index + 1] - let incomingLength = hypot(corner.x - previous.x, corner.y - previous.y) - let outgoingLength = hypot(next.x - corner.x, next.y - corner.y) - let incoming = min(radius, incomingLength / 2) - let outgoing = min(radius, outgoingLength / 2) - let before = CGPoint( - x: corner.x + (previous.x - corner.x) * incoming / max(incomingLength, 0.001), - y: corner.y + (previous.y - corner.y) * incoming / max(incomingLength, 0.001) - ) - let after = CGPoint( - x: corner.x + (next.x - corner.x) * outgoing / max(outgoingLength, 0.001), - y: corner.y + (next.y - corner.y) * outgoing / max(outgoingLength, 0.001) - ) - path.addLine(to: before) - path.addQuadCurve(to: after, control: corner) + path.addCurve(to: curve.end, control1: curve.control1, control2: curve.control2) } - if let last = points.last { path.addLine(to: last) } } private func ribbonPath(_ curve: TrafficGraphCurve, width: CGFloat, offset: CGFloat = 0) -> Path { @@ -1295,17 +1299,9 @@ private struct TrafficGraphCanvas: View { let bottom = curve.offset(by: offset + width / 2) return Path { path in path.move(to: top.start) - if top.routingPoints.count >= 2 { - path.addLines(Array(top.routingPoints.dropFirst())) - } else { - path.addCurve(to: top.end, control1: top.control1, control2: top.control2) - } + path.addCurve(to: top.end, control1: top.control1, control2: top.control2) path.addLine(to: bottom.end) - if bottom.routingPoints.count >= 2 { - path.addLines(Array(bottom.routingPoints.dropLast().reversed())) - } else { - path.addCurve(to: bottom.start, control1: bottom.control2, control2: bottom.control1) - } + path.addCurve(to: bottom.start, control1: bottom.control2, control2: bottom.control1) path.closeSubpath() } } diff --git a/FlowlightTests/AgentLauncherTests.swift b/FlowlightTests/AgentLauncherTests.swift index b453728e..bdc91f2b 100644 --- a/FlowlightTests/AgentLauncherTests.swift +++ b/FlowlightTests/AgentLauncherTests.swift @@ -34,6 +34,21 @@ final class AgentLauncherTests: XCTestCase { XCTAssertTrue(found!.restartEndsSessions) } + func testLauncherChainKeepsGhosttyAboveHerdrWithoutDuplicatingHerdr() { + let ancestors = [entry(23900, "-zsh"), + entry(23361, "herdr", "", ["/Users/x/.local/bin/herdr", "server"]), + entry(23360, "herdr"), + entry(1700, "-zsh"), + entry(1617, "ghostty", "com.mitchellh.ghostty")] + + let chain = AgentLauncher.findChain(ancestors: ancestors) + + XCTAssertEqual(chain.map(\.pid), [23361, 1617]) + XCTAssertEqual(chain.map(\.name), ["herdr", "ghostty"]) + XCTAssertEqual(chain.first?.argv, ["/Users/x/.local/bin/herdr", "server"]) + XCTAssertEqual(AgentLauncher.find(ancestors: ancestors), chain.first) + } + /// Its own argv, because `herdr server` is not `herdr` — restarting the bare name would start something /// that does not spawn agents. func testASupervisorIsRestartedWithTheArgumentsItIsRunning() { diff --git a/FlowlightTests/DatabaseTests.swift b/FlowlightTests/DatabaseTests.swift index f1a6b5fc..0aab81d1 100644 --- a/FlowlightTests/DatabaseTests.swift +++ b/FlowlightTests/DatabaseTests.swift @@ -862,15 +862,19 @@ final class DatabaseTests: XCTestCase { let decoded = try JSONDecoder().decode([TrafficBatch].self, from: Data(legacy.utf8)) XCTAssertNil(decoded[0].records[0].key.parentAgent) XCTAssertNil(decoded[0].records[0].key.launcherBundleID) + XCTAssertNil(decoded[0].records[0].key.upstreamLauncherBundleID) XCTAssertNil(decoded[0].records[0].key.routeProxyBundleID) } func testVisualizationProvenanceRoundTripsThroughRollups() throws { let t0: Int64 = 1_700_000_000 - (1_700_000_000 % 3600) var routed = key("claude", "api.anthropic.com", ip: "203.0.113.8") - routed.launcherBundleID = "com.mitchellh.ghostty" - routed.launcherName = "Ghostty" - routed.launcherPath = "/Applications/Ghostty.app" + routed.launcherBundleID = "herdr" + routed.launcherName = "Herdr" + routed.launcherPath = "/Users/x/.local/bin/herdr" + routed.upstreamLauncherBundleID = "com.mitchellh.ghostty" + routed.upstreamLauncherName = "Ghostty" + routed.upstreamLauncherPath = "/Applications/Ghostty.app/Contents/MacOS/ghostty" routed.routeProxyBundleID = "com.flowlight.app" routed.routeProxyName = "Flowlight" routed.routeProxyPath = "/Applications/Flowlight.app" @@ -886,12 +890,37 @@ final class DatabaseTests: XCTestCase { from: Date(timeIntervalSince1970: TimeInterval(t0)), to: Date(timeIntervalSince1970: TimeInterval(t0 + 3_600)) ) - XCTAssertEqual(rows.first?.launcherBundleID, "com.mitchellh.ghostty") + XCTAssertEqual(rows.first?.launcherBundleID, "herdr") + XCTAssertEqual(rows.first?.upstreamLauncherBundleID, "com.mitchellh.ghostty") + XCTAssertEqual(rows.first?.upstreamLauncherName, "Ghostty") XCTAssertEqual(rows.first?.routeProxyBundleID, "com.flowlight.app") XCTAssertEqual(rows.first?.counters, FlowCounters(bytesIn: 10, bytesOut: 20, flows: 1)) } } + func testUpstreamLauncherIsPartOfExactFlowIdentity() throws { + let timestamp: Int64 = 1_700_000_100 + var ghostty = key("claude", "api.anthropic.com", ip: "203.0.113.8") + ghostty.launcherBundleID = "herdr" + ghostty.launcherName = "Herdr" + ghostty.upstreamLauncherBundleID = "com.mitchellh.ghostty" + ghostty.upstreamLauncherName = "Ghostty" + var terminal = ghostty + terminal.upstreamLauncherBundleID = "com.apple.Terminal" + terminal.upstreamLauncherName = "Terminal" + + try db.insert([TrafficBatch(timestamp: timestamp, records: [ + TrafficRecord(key: ghostty, counters: FlowCounters(bytesOut: 10, flows: 1)), + TrafficRecord(key: terminal, counters: FlowCounters(bytesOut: 20, flows: 1)) + ])]) + + let rows = try db.raw( + "SELECT upstream_launcher_bundle_id, bytes_out FROM flows_1s ORDER BY upstream_launcher_bundle_id" + ) { ($0.text(0), $0.int(1)) } + XCTAssertEqual(rows.map(\.0), ["com.apple.Terminal", "com.mitchellh.ghostty"]) + XCTAssertEqual(rows.map(\.1), [20, 10]) + } + func testLegacyMergedAttributionIsUntrustedWithoutLosingTraffic() throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("legacy-attribution-\(UUID()).sqlite") let legacy = try SQLiteConnection(path: url.path) diff --git a/FlowlightTests/DemoDataTests.swift b/FlowlightTests/DemoDataTests.swift index e4200f6f..29392ef4 100644 --- a/FlowlightTests/DemoDataTests.swift +++ b/FlowlightTests/DemoDataTests.swift @@ -79,6 +79,43 @@ final class DemoDataTests: XCTestCase { observed.reduce(into: FlowCounters()) { $0 += $1.counters }) } + func testAIVisualizationFixtureShowsGhosttyHerdrClaudeLineage() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("demo-lineage-\(UUID()).sqlite") + defer { try? FileManager.default.removeItem(at: url) } + let db = try TrafficDatabase(url: url) + let now = Date() + try DemoData.seed(db, now: now) + + let rows = try db.aiVisualizationRows(.hour, from: now.addingTimeInterval(-24 * 3600), to: now) + let snapshot = AITrafficGraphBuilder.build(rows: rows, limits: .unbounded) + let ghostty = try XCTUnwrap(snapshot.nodes.first { $0.kind == .launcher && $0.title == "Ghostty" }) + let herdr = try XCTUnwrap(snapshot.nodes.first { $0.kind == .launcher && $0.title == "Herdr" }) + let claude = try XCTUnwrap(snapshot.nodes.first { $0.kind == .agent && $0.filter.bundleID == "claude" }) + + XCTAssertTrue(snapshot.edges.contains { + $0.relationship == .launched && $0.source == ghostty.id && $0.target == herdr.id + }) + XCTAssertTrue(snapshot.edges.contains { + $0.relationship == .launched && $0.source == herdr.id && $0.target == claude.id + }) + + let map = AITrafficGraphBuilder.build(rows: rows, limits: .standard) + let mapGhostty = try XCTUnwrap(map.nodes.first { $0.kind == .launcher && $0.title == "Ghostty" }) + let mapHerdr = try XCTUnwrap(map.nodes.first { $0.kind == .launcher && $0.title == "Herdr" }) + let mapClaude = try XCTUnwrap(map.nodes.first { $0.kind == .agent && $0.filter.bundleID == "claude" }) + XCTAssertTrue(map.edges.contains { + $0.relationship == .launched && $0.source == mapGhostty.id && $0.target == mapHerdr.id + }) + let layout = TrafficGraphLayout.layout( + snapshot: map, + in: CGRect(x: 0, y: 0, width: 1_200, height: 760) + ) + XCTAssertLessThan(try XCTUnwrap(layout.nodeFrames[mapGhostty.id]).maxX, + try XCTUnwrap(layout.nodeFrames[mapHerdr.id]).minX) + XCTAssertLessThan(try XCTUnwrap(layout.nodeFrames[mapHerdr.id]).maxX, + try XCTUnwrap(layout.nodeFrames[mapClaude.id]).minX) + } + func testPackReviewFixtureWritesStaleConsentWithoutEnablingThePack() throws { let url = FileManager.default.temporaryDirectory.appendingPathComponent("demo-pack-review-\(UUID()).sqlite") defer { try? FileManager.default.removeItem(at: url) } diff --git a/FlowlightTests/MCPAttributionTests.swift b/FlowlightTests/MCPAttributionTests.swift index 985a8594..1241fa65 100644 --- a/FlowlightTests/MCPAttributionTests.swift +++ b/FlowlightTests/MCPAttributionTests.swift @@ -40,7 +40,9 @@ final class MCPAttributionTests: XCTestCase { 200: ProcessSnapshot(pid: 200, ppid: 150, bundleID: "curl", name: "curl", argv: ["curl"]), 150: ProcessSnapshot(pid: 150, ppid: 100, bundleID: "zsh", name: "zsh", argv: ["zsh"]), 100: ProcessSnapshot(pid: 100, ppid: 50, bundleID: "claude", name: "claude", argv: ["claude"]), - 50: ProcessSnapshot(pid: 50, ppid: 1, bundleID: "com.mitchellh.ghostty", name: "Ghostty", + 50: ProcessSnapshot(pid: 50, ppid: 40, bundleID: "herdr", name: "herdr", + argv: ["/Users/x/.local/bin/herdr", "server"]), + 40: ProcessSnapshot(pid: 40, ppid: 1, bundleID: "com.mitchellh.ghostty", name: "Ghostty", argv: ["/Applications/Ghostty.app/Contents/MacOS/ghostty"]) ] let attributor = AgentAttributor(processLookup: { table[$0] }) @@ -55,8 +57,12 @@ final class MCPAttributionTests: XCTestCase { ])[0].records[0].key XCTAssertEqual(enriched.parentAgent, "claude") - XCTAssertEqual(enriched.launcherBundleID, "com.mitchellh.ghostty") - XCTAssertEqual(enriched.launcherName, "Ghostty") + XCTAssertEqual(enriched.launcherBundleID, "herdr") + XCTAssertEqual(enriched.launcherName, "herdr") + XCTAssertEqual(enriched.launcherPath, "/Users/x/.local/bin/herdr") + XCTAssertEqual(enriched.upstreamLauncherBundleID, "com.mitchellh.ghostty") + XCTAssertEqual(enriched.upstreamLauncherName, "Ghostty") + XCTAssertEqual(enriched.upstreamLauncherPath, "/Applications/Ghostty.app/Contents/MacOS/ghostty") } func testSignatureAndMatching() { diff --git a/FlowlightTests/TrafficGraphTests.swift b/FlowlightTests/TrafficGraphTests.swift index 83fd37dd..a2bf9624 100644 --- a/FlowlightTests/TrafficGraphTests.swift +++ b/FlowlightTests/TrafficGraphTests.swift @@ -101,6 +101,7 @@ final class TrafficGraphTests: XCTestCase { parentName: String = "", mcp: String = "", launcher: (id: String, name: String, path: String)? = nil, + upstreamLauncher: (id: String, name: String, path: String)? = nil, relay: (id: String, name: String, path: String)? = nil, protocol appProtocol: String = "https", channel: NetworkChannel = .ip @@ -121,6 +122,9 @@ final class TrafficGraphTests: XCTestCase { launcherBundleID: launcher?.id ?? "", launcherName: launcher?.name ?? "", launcherPath: launcher?.path ?? "", + upstreamLauncherBundleID: upstreamLauncher?.id ?? "", + upstreamLauncherName: upstreamLauncher?.name ?? "", + upstreamLauncherPath: upstreamLauncher?.path ?? "", routeProxyBundleID: relay?.id ?? "", routeProxyName: relay?.name ?? "", routeProxyPath: relay?.path ?? "", @@ -327,6 +331,52 @@ final class TrafficGraphTests: XCTestCase { XCTAssertEqual(snapshot.total, FlowCounters(), "hidden observed rows must not leak into searched totals") } + func testAIBuilderPreservesGhosttyHerdrAgentLauncherChain() throws { + let rows = [ + row( + app: "claude", name: "Claude Code", domain: "api.anthropic.com", ip: "203.0.113.8", + bytesOut: 25, + launcher: ("herdr", "Herdr", "/Users/x/.local/bin/herdr"), + upstreamLauncher: ( + "com.mitchellh.ghostty", "Ghostty", + "/Applications/Ghostty.app/Contents/MacOS/ghostty" + ) + ) + ] + + let snapshot = AITrafficGraphBuilder.build(rows: rows, limits: .unbounded) + let herdr = try XCTUnwrap(snapshot.nodes.first { $0.kind == .launcher && $0.title == "Herdr" }) + let ghostty = try XCTUnwrap(snapshot.nodes.first { $0.kind == .launcher && $0.title == "Ghostty" }) + let claude = try XCTUnwrap(snapshot.nodes.first { $0.kind == .agent && $0.filter.bundleID == "claude" }) + + XCTAssertTrue(snapshot.edges.contains { + $0.relationship == .launched && $0.source == ghostty.id && $0.target == herdr.id + }) + XCTAssertTrue(snapshot.edges.contains { + $0.relationship == .launched && $0.source == herdr.id && $0.target == claude.id + }) + XCTAssertEqual(snapshot.hidden, .none) + + let layout = TrafficGraphLayout.layout( + snapshot: snapshot, + in: CGRect(x: 0, y: 0, width: 1_200, height: 760) + ) + let ghosttyFrame = try XCTUnwrap(layout.nodeFrames[ghostty.id]) + let herdrFrame = try XCTUnwrap(layout.nodeFrames[herdr.id]) + let claudeFrame = try XCTUnwrap(layout.nodeFrames[claude.id]) + XCTAssertLessThan(ghosttyFrame.maxX, herdrFrame.minX) + XCTAssertLessThan(herdrFrame.maxX, claudeFrame.minX) + + let capped = AITrafficGraphBuilder.build( + rows: rows, + limits: AITrafficGraphLimits(maxAgents: 1, maxTools: 0, maxDestinations: 1, + maxObservedEdges: 1, maxLaunchers: 1, maxBlockedEdges: 0) + ) + XCTAssertTrue(capped.nodes.contains { $0.kind == .launcher && $0.title == "Herdr" }, + "the immediate restart target has priority when launcher capacity is scarce") + XCTAssertFalse(capped.nodes.contains { $0.kind == .launcher && $0.title == "Ghostty" }) + } + func testVisualizationSelectionClearsWhenSearchContextChanges() { XCTAssertTrue(VisualizeSelectionPolicy.shouldClear(previousQuery: "", newQuery: "uploads.example")) XCTAssertFalse(VisualizeSelectionPolicy.shouldClear(previousQuery: "uploads.example", @@ -2577,6 +2627,71 @@ final class TrafficGraphTests: XCTestCase { XCTAssertEqual(Set(sourceFocused.destinationOptions.map(\.title)), ["one.example", "shared.example"]) } + func testEndpointProjectionSupportsMultipleSourcesAndDestinations() throws { + let rows = [ + row(app: "app.one", domain: "one.example", ip: "203.0.113.1", bytesOut: 10), + row(app: "app.one", domain: "shared.example", ip: "203.0.113.2", bytesOut: 20), + row(app: "app.two", domain: "one.example", ip: "203.0.113.1", bytesOut: 30), + row(app: "app.two", domain: "shared.example", ip: "203.0.113.2", bytesOut: 40), + row(app: "app.three", domain: "shared.example", ip: "203.0.113.2", bytesOut: 50) + ] + let complete = TrafficGraphBuilder.buildForPresentation(rows: rows, mapLimits: .unbounded).complete + let appOne = try XCTUnwrap(complete.nodes.first { $0.filter.bundleID == "app.one" }?.id) + let appTwo = try XCTUnwrap(complete.nodes.first { $0.filter.bundleID == "app.two" }?.id) + let appThree = try XCTUnwrap(complete.nodes.first { $0.filter.bundleID == "app.three" }?.id) + let one = try XCTUnwrap(complete.nodes.first { $0.title == "one.example" }?.id) + let shared = try XCTUnwrap(complete.nodes.first { $0.title == "shared.example" }?.id) + + let query = TrafficGraphEndpointQuery( + sources: [appOne, appTwo], + destinations: [one, shared] + ) + let scoped = TrafficGraphEndpointProjector.scope(complete, query: query) + let compatibleSources = TrafficGraphEndpointProjector.sourceOptions( + in: complete, + matchingDestinations: [one, shared] + ) + let compatibleDestinations = TrafficGraphEndpointProjector.destinationOptions( + in: complete, + matchingSources: [appOne, appTwo] + ) + + XCTAssertEqual(scoped.edges.filter { $0.relationship == .observed }.count, 4) + XCTAssertEqual(scoped.visibleTotal.bytesOut, 100) + XCTAssertEqual(Set(compatibleSources.map(\.id)), [appOne, appTwo]) + XCTAssertFalse(compatibleSources.contains { $0.id == appThree }) + XCTAssertEqual(Set(compatibleDestinations.map(\.id)), [one, shared]) + } + + func testEndpointMultiSelectionKeepsBlockedOnlyPairsCompatible() throws { + let blocked = [ + BlockedAttemptRow(bundleID: "app.one", appName: "One", agentKey: "", agentName: "", + destination: "blocked.example", port: 443, count: 2, + category: "tracking", source: "managed-pack:test", detail: ""), + BlockedAttemptRow(bundleID: "app.two", appName: "Two", agentKey: "", agentName: "", + destination: "blocked.example", port: 443, count: 3, + category: "tracking", source: "managed-pack:test", detail: "") + ] + let complete = TrafficGraphBuilder.buildForPresentation( + rows: [BreakdownRow](), blockedAttempts: blocked, mapLimits: .unbounded + ).complete + let sources = Set(complete.nodes.filter { $0.kind == .app }.map(\.id)) + let destination = try XCTUnwrap(complete.nodes.first { $0.title == "blocked.example" }?.id) + + let options = TrafficGraphEndpointProjector.destinationOptions( + in: complete, + matchingSources: sources + ) + let scoped = TrafficGraphEndpointProjector.scope( + complete, + query: TrafficGraphEndpointQuery(sources: sources, destinations: [destination]) + ) + + XCTAssertEqual(options.map(\.id), [destination]) + XCTAssertEqual(options.first?.enforcementCount, 5) + XCTAssertEqual(scoped.edges.filter { $0.relationship == .blocked }.count, 2) + } + func testEndpointProjectionKeepsASelectedQuietSourceAndDestinationExact() throws { let rows = (0..<12).map { index in row(app: "app.\(index)", domain: "host\(index).example", @@ -2771,7 +2886,7 @@ final class TrafficGraphTests: XCTestCase { XCTAssertGreaterThan(build.map.hidden.edgeCount, 0) } - func testEndpointSelectionIsClearedWhenItsOptionDisappears() { + func testEndpointSelectionPrunesOnlyOptionsThatDisappear() { let retained = TrafficGraphEndpointOption( id: TrafficGraphNodeID(rawValue: "app:retained"), title: "Retained", detail: "", counters: FlowCounters() @@ -2779,11 +2894,11 @@ final class TrafficGraphTests: XCTestCase { let missing = TrafficGraphNodeID(rawValue: "app:missing") XCTAssertEqual( - VisualizeSelectionPolicy.validEndpointSelection(retained.id, options: [retained]), - retained.id + VisualizeSelectionPolicy.validEndpointSelection([retained.id, missing], options: [retained]), + [retained.id] ) - XCTAssertNil(VisualizeSelectionPolicy.validEndpointSelection(missing, options: [retained])) - XCTAssertNil(VisualizeSelectionPolicy.validEndpointSelection(nil, options: [retained])) + XCTAssertEqual(VisualizeSelectionPolicy.validEndpointSelection([missing], options: [retained]), []) + XCTAssertEqual(VisualizeSelectionPolicy.validEndpointSelection([], options: [retained]), []) } func testAllApplicationsMapReconcilesWithCompleteReportTotals() { @@ -3547,10 +3662,14 @@ final class TrafficGraphTests: XCTestCase { } for step in 1..<100 { let point = curve.point(at: CGFloat(step) / 100) - XCTAssertFalse( - obstacles.values.contains { $0.insetBy(dx: -10, dy: -10).contains(point) }, - "demo route \(edge.id.rawValue) crosses an unrelated card near \(point)" - ) + if let obstacle = obstacles.first(where: { + $0.value.insetBy(dx: -10, dy: -10).contains(point) + }) { + XCTFail( + "demo route \(edge.id.rawValue) crosses \(obstacle.key.rawValue) " + + "at \(obstacle.value) near \(point); curve \(curve)" + ) + } } } for leftIndex in build.map.edges.indices { @@ -4137,6 +4256,25 @@ final class TrafficGraphTests: XCTestCase { XCTAssertFalse(TrafficGraphViewportPolicy.shouldAutoFit(isUserControlled: true)) } + func testFocusedTopologyDoesNotPreserveStrandedLayoutPositions() throws { + let snapshot = TrafficGraphBuilder.build(rows: [ + row(app: "app.one", domain: "one.example", ip: "203.0.113.1", bytesOut: 10), + row(app: "app.two", domain: "two.example", ip: "203.0.113.2", bytesOut: 20) + ], limits: .unbounded) + let layout = TrafficGraphLayout.layout( + snapshot: snapshot, + in: CGRect(x: 0, y: 0, width: 900, height: 700) + ) + let source = try XCTUnwrap(snapshot.nodes.first { $0.filter.bundleID == "app.one" }?.id) + let focused = TrafficGraphEndpointProjector.scope( + snapshot, + query: TrafficGraphEndpointQuery(source: source) + ) + + XCTAssertTrue(TrafficGraphViewportPolicy.shouldPreserveLayout(snapshot: snapshot, previous: layout)) + XCTAssertFalse(TrafficGraphViewportPolicy.shouldPreserveLayout(snapshot: focused, previous: layout)) + } + func testCancelledLargeLayoutDoesNotPublishPartialGeometry() async { let snapshot = TrafficGraphBuilder.build(rows: (0..<600).map { index in row( diff --git a/Shared/AgentLauncher.swift b/Shared/AgentLauncher.swift index 7d153d95..d80bf679 100644 --- a/Shared/AgentLauncher.swift +++ b/Shared/AgentLauncher.swift @@ -74,12 +74,36 @@ enum AgentLauncher { /// Shells are skipped. The first thing above them decides: a terminal means the old behaviour is right, and /// anything else is a supervisor whose environment its children will inherit. static func find(ancestors: [(pid: Int32, name: String, bundleID: String, argv: [String])]) -> Launcher? { - for entry in ancestors { + findChain(ancestors: ancestors).first + } + + /// Distinct non-shell launchers, nearest first. A supervisor may have both a client and daemon process in the + /// ancestry; keep the nearest exact process once, then continue to the terminal or supervisor above it. + static func findChain( + ancestors: [(pid: Int32, name: String, bundleID: String, argv: [String])] + ) -> [Launcher] { + var seen: Set = [] + return ancestors.compactMap { entry in let kind = kind(name: entry.name, bundleID: entry.bundleID) - if kind == .shell { continue } - return Launcher(pid: entry.pid, name: entry.name, bundleID: entry.bundleID, kind: kind, argv: entry.argv) + guard kind != .shell else { return nil } + let identity: String + if !entry.bundleID.isEmpty { + identity = "bundle:" + entry.bundleID.lowercased() + } else { + let executable = entry.argv.first ?? entry.name + let undashed = executable.hasPrefix("-") ? String(executable.dropFirst()) : executable + let bare = undashed.split(separator: "/").last.map(String.init) ?? undashed + identity = "exec:" + bare.lowercased() + } + guard seen.insert(identity).inserted else { return nil } + return Launcher( + pid: entry.pid, + name: entry.name, + bundleID: entry.bundleID, + kind: kind, + argv: entry.argv + ) } - return nil } /// The command that starts a supervisor again, from the argv it is running with. diff --git a/Shared/TrafficModels.swift b/Shared/TrafficModels.swift index cabe723d..e1952eab 100644 --- a/Shared/TrafficModels.swift +++ b/Shared/TrafficModels.swift @@ -31,6 +31,10 @@ struct FlowKey: Hashable, Codable, Sendable { var launcherBundleID: String? = nil var launcherName: String? = nil var launcherPath: String? = nil + /// The next distinct terminal or supervisor above `launcher*`, when capture observed one. + var upstreamLauncherBundleID: String? = nil + var upstreamLauncherName: String? = nil + var upstreamLauncherPath: String? = nil /// An explicit local relay this counted connection traversed. Counters remain on this one record; graph route /// evidence must never restore the hidden loopback leg and count it again. var routeProxyBundleID: String? = nil @@ -130,6 +134,9 @@ extension FlowKey { launcherBundleID = try c.decodeIfPresent(String.self, forKey: .launcherBundleID) launcherName = try c.decodeIfPresent(String.self, forKey: .launcherName) launcherPath = try c.decodeIfPresent(String.self, forKey: .launcherPath) + upstreamLauncherBundleID = try c.decodeIfPresent(String.self, forKey: .upstreamLauncherBundleID) + upstreamLauncherName = try c.decodeIfPresent(String.self, forKey: .upstreamLauncherName) + upstreamLauncherPath = try c.decodeIfPresent(String.self, forKey: .upstreamLauncherPath) routeProxyBundleID = try c.decodeIfPresent(String.self, forKey: .routeProxyBundleID) routeProxyName = try c.decodeIfPresent(String.self, forKey: .routeProxyName) routeProxyPath = try c.decodeIfPresent(String.self, forKey: .routeProxyPath) diff --git a/docs/404.html b/docs/404.html index 423404f5..0aad1441 100644 --- a/docs/404.html +++ b/docs/404.html @@ -86,7 +86,7 @@

That page isn't here

Try the home page,

diff --git a/docs/about/index.html b/docs/about/index.html index c5814cbc..4e334402 100644 --- a/docs/about/index.html +++ b/docs/about/index.html @@ -153,7 +153,7 @@

Thanks

diff --git a/docs/de/about/index.html b/docs/de/about/index.html index 1c1b5d39..4fd81672 100644 --- a/docs/de/about/index.html +++ b/docs/de/about/index.html @@ -152,7 +152,7 @@

Dank

diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html index 0cc353bd..1431fcc4 100644 --- a/docs/de/docs/index.html +++ b/docs/de/docs/index.html @@ -76,7 +76,7 @@

Dokumentation

Flowlight benutzen

-

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.16.10, macOS 15 oder neuer.

+

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.16.11, macOS 15 oder neuer.

diff --git a/docs/de/index.html b/docs/de/index.html index 95289eab..142361b4 100644 --- a/docs/de/index.html +++ b/docs/de/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Sieh, was deine Apps im Netz tun.
brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+UniversalGPL-3.0Keine Telemetrie

+

v0.16.11macOS 15+UniversalGPL-3.0Keine Telemetrie

@@ -589,7 +589,7 @@

Wisse, was deinen Mac verlässt.

diff --git a/docs/de/privacy/index.html b/docs/de/privacy/index.html index 7633eee5..d609f714 100644 --- a/docs/de/privacy/index.html +++ b/docs/de/privacy/index.html @@ -189,7 +189,7 @@

Kontakt

diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html index 2e187637..a06b4818 100644 --- a/docs/de/threat-model/index.html +++ b/docs/de/threat-model/index.html @@ -225,7 +225,7 @@

Eine Schwachstelle melden

diff --git a/docs/docs/index.html b/docs/docs/index.html index 095cae3f..501b3e1f 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -76,7 +76,7 @@

Documentation

Using Flowlight

-

Everything from the first launch to tuning the agent rules. Flowlight 0.16.10, macOS 15 or later.

+

Everything from the first launch to tuning the agent rules. Flowlight 0.16.11, macOS 15 or later.

diff --git a/docs/es/about/index.html b/docs/es/about/index.html index 6c7cf105..7537a105 100644 --- a/docs/es/about/index.html +++ b/docs/es/about/index.html @@ -152,7 +152,7 @@

Agradecimientos

diff --git a/docs/es/docs/index.html b/docs/es/docs/index.html index 69184bbf..50d64e57 100644 --- a/docs/es/docs/index.html +++ b/docs/es/docs/index.html @@ -76,7 +76,7 @@

Documentación

Usar Flowlight

-

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.16.10, macOS 15 o posterior.

+

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.16.11, macOS 15 o posterior.

diff --git a/docs/es/index.html b/docs/es/index.html index 60e83bd9..29c90632 100644 --- a/docs/es/index.html +++ b/docs/es/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Observa la actividad de red de tus apps.

brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+UniversalGPL-3.0Sin telemetría

+

v0.16.11macOS 15+UniversalGPL-3.0Sin telemetría

@@ -589,7 +589,7 @@

Ten claro qué sale de tu Mac.

diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html index 8da37032..33df96f8 100644 --- a/docs/es/privacy/index.html +++ b/docs/es/privacy/index.html @@ -189,7 +189,7 @@

Contacto

diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html index 995594d9..00f96ba9 100644 --- a/docs/es/threat-model/index.html +++ b/docs/es/threat-model/index.html @@ -221,7 +221,7 @@

Informar de una vulnerabilidad

diff --git a/docs/fr/about/index.html b/docs/fr/about/index.html index ef0beef5..0414bd93 100644 --- a/docs/fr/about/index.html +++ b/docs/fr/about/index.html @@ -152,7 +152,7 @@

Remerciements

diff --git a/docs/fr/docs/index.html b/docs/fr/docs/index.html index 7928b713..151447ea 100644 --- a/docs/fr/docs/index.html +++ b/docs/fr/docs/index.html @@ -76,7 +76,7 @@

Documentation

Utiliser Flowlight

-

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.16.10, macOS 15 ou version ultérieure.

+

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.16.11, macOS 15 ou version ultérieure.

diff --git a/docs/fr/index.html b/docs/fr/index.html index 440407c2..655b5b8a 100644 --- a/docs/fr/index.html +++ b/docs/fr/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Voyez l’activité réseau de vos apps.

brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+UniverselGPL-3.0Sans télémétrie

+

v0.16.11macOS 15+UniverselGPL-3.0Sans télémétrie

@@ -589,7 +589,7 @@

Sachez ce qui quitte votre Mac.

diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html index 86f7fb23..aafbadd3 100644 --- a/docs/fr/privacy/index.html +++ b/docs/fr/privacy/index.html @@ -189,7 +189,7 @@

Contact

diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html index 5acdc5fe..dcdba3de 100644 --- a/docs/fr/threat-model/index.html +++ b/docs/fr/threat-model/index.html @@ -224,7 +224,7 @@

Signaler une vulnérabilité

diff --git a/docs/index.html b/docs/index.html index 25b144b0..034897de 100644 --- a/docs/index.html +++ b/docs/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

See your apps' network activity.
brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+UniversalGPL-3.0No telemetry

+

v0.16.11macOS 15+UniversalGPL-3.0No telemetry

@@ -612,7 +612,7 @@

Know what leaves your Mac.

diff --git a/docs/it/about/index.html b/docs/it/about/index.html index 3e56fe4f..432e72b4 100644 --- a/docs/it/about/index.html +++ b/docs/it/about/index.html @@ -152,7 +152,7 @@

Ringraziamenti

diff --git a/docs/it/docs/index.html b/docs/it/docs/index.html index 666352de..892631a5 100644 --- a/docs/it/docs/index.html +++ b/docs/it/docs/index.html @@ -76,7 +76,7 @@

Documentazione

Usare Flowlight

-

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.16.10, macOS 15 o successivo.

+

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.16.11, macOS 15 o successivo.

diff --git a/docs/it/index.html b/docs/it/index.html index 688c318f..c577274c 100644 --- a/docs/it/index.html +++ b/docs/it/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Vedi la rete delle tue app.
Ca

brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+UniversaleGPL-3.0Nessuna telemetria

+

v0.16.11macOS 15+UniversaleGPL-3.0Nessuna telemetria

@@ -589,7 +589,7 @@

Sappi che cosa esce dal tuo Mac.

diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html index e7082466..ae1b4834 100644 --- a/docs/it/privacy/index.html +++ b/docs/it/privacy/index.html @@ -189,7 +189,7 @@

Contatti

diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html index 062e4de4..7cc54985 100644 --- a/docs/it/threat-model/index.html +++ b/docs/it/threat-model/index.html @@ -223,7 +223,7 @@

Segnalare una vulnerabilità

diff --git a/docs/ja/about/index.html b/docs/ja/about/index.html index d8e35c55..294548f6 100644 --- a/docs/ja/about/index.html +++ b/docs/ja/about/index.html @@ -153,7 +153,7 @@

謝辞

diff --git a/docs/ja/docs/index.html b/docs/ja/docs/index.html index 49065e7e..f5ce9fff 100644 --- a/docs/ja/docs/index.html +++ b/docs/ja/docs/index.html @@ -76,7 +76,7 @@

ドキュメント

Flowlight の使い方

-

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.16.10、macOS 15 以降。

+

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.16.11、macOS 15 以降。

diff --git a/docs/ja/index.html b/docs/ja/index.html index 10fb1679..b9bec666 100644 --- a/docs/ja/index.html +++ b/docs/ja/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

アプリの通信が見える。
brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+ユニバーサルGPL-3.0テレメトリなし

+

v0.16.11macOS 15+ユニバーサルGPL-3.0テレメトリなし

@@ -588,7 +588,7 @@

Mac から何が出ていくのかを知る。

diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html index 4c3b043f..5cbe71fd 100644 --- a/docs/ja/privacy/index.html +++ b/docs/ja/privacy/index.html @@ -187,7 +187,7 @@

連絡先

diff --git a/docs/ja/threat-model/index.html b/docs/ja/threat-model/index.html index 5c8945e7..8ed298f2 100644 --- a/docs/ja/threat-model/index.html +++ b/docs/ja/threat-model/index.html @@ -222,7 +222,7 @@

脆弱性を報告する

diff --git a/docs/ko/about/index.html b/docs/ko/about/index.html index 0861c9b9..45ba6ff8 100644 --- a/docs/ko/about/index.html +++ b/docs/ko/about/index.html @@ -153,7 +153,7 @@

감사

diff --git a/docs/ko/docs/index.html b/docs/ko/docs/index.html index eec7c9fe..a2dee573 100644 --- a/docs/ko/docs/index.html +++ b/docs/ko/docs/index.html @@ -76,7 +76,7 @@

문서

Flowlight 사용하기

-

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.16.10, macOS 15 이상.

+

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.16.11, macOS 15 이상.

diff --git a/docs/ko/index.html b/docs/ko/index.html index f28fcad2..77a17d81 100644 --- a/docs/ko/index.html +++ b/docs/ko/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

앱이 무엇을 하는지 봅니다.

brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+유니버설GPL-3.0텔레메트리 없음

+

v0.16.11macOS 15+유니버설GPL-3.0텔레메트리 없음

@@ -589,7 +589,7 @@

내 Mac에서 무엇이 나가는지 아세요.

diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html index 1cda5d26..92979235 100644 --- a/docs/ko/privacy/index.html +++ b/docs/ko/privacy/index.html @@ -190,7 +190,7 @@

문의

diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html index 1acb3a8f..0141b93e 100644 --- a/docs/ko/threat-model/index.html +++ b/docs/ko/threat-model/index.html @@ -221,7 +221,7 @@

취약점 신고하기

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 5b049795..4f704db1 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -62,7 +62,7 @@ Documentation Using Flowlight - Everything from the first launch to tuning the agent rules. Flowlight 0.16.10, macOS 15 or later. + Everything from the first launch to tuning the agent rules. Flowlight 0.16.11, macOS 15 or later. On this page @@ -920,7 +920,7 @@ Understand your AI agents. brew install --cask xinbetween/tap/flowlightCopy - v0.16.10macOS 15+UniversalGPL-3.0No telemetry + v0.16.11macOS 15+UniversalGPL-3.0No telemetry connectionslive @@ -1663,9 +1663,22 @@ Releases Downloads, checksums and full notes for each version are on GitHub Releases. - 0.16.10 + 0.16.11 November 3, 2026Latest + Keep every route in one visual language. Every connection is one smooth cubic curve; nearby obstacle corridors are preferred over deep canvas-edge U-turns, while card clearance, endpoint attachment, route separation, hit testing, and bounded layout remain intact. + + Compare several endpoints at once. Source and destination selectors now support complete multi-selection with exclusive Any behavior, all-to-all evidence compatibility, blocked-only pairs, accessible selected states, and one-click Reset. + + Show who started the starter. Capture-time launcher evidence now preserves one upstream non-shell launcher, so AI activity can truthfully show Ghostty → Herdr → Claude while Herdr remains the immediate restart target. + + Refit each changed investigation. Endpoint scope changes discard incompatible coordinates and automatically center the new topology; Traffic and AI activity transitions show a flipping Flowlight mark until the final layout is ready instead of flashing partial geometry. + + Make installation choices scan as one unit. Mac App Store, direct Mac download, and GitHub share one equal-height row, with the complete copyable Homebrew command directly below. + + 0.16.10 +November 3, 2026 + Start with the traffic that matters most. Traffic shows the ten busiest applications and AI activity shows the ten busiest proved agent roots, ranked by complete received-plus-sent traffic with deterministic ties. Trace any source to any destination. Two searchable optional selectors cover every endpoint combination, mutually limit choices to pairs with observed or blocked evidence, and provide a one-click Reset while revealing the complete connected semantic neighborhood. diff --git a/docs/llms.txt b/docs/llms.txt index 3c28ec3b..1d57ee58 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,6 +1,6 @@ # Flowlight -> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.16.10. +> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.16.11. - [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg) - [Source code](https://github.com/xinbetween/flowlight) diff --git a/docs/plugins/index.html b/docs/plugins/index.html index 7c1cc8b5..8bd8b91e 100644 --- a/docs/plugins/index.html +++ b/docs/plugins/index.html @@ -212,7 +212,7 @@

Review checklist

diff --git a/docs/privacy/index.html b/docs/privacy/index.html index 135f4c9f..bb014624 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -196,7 +196,7 @@

Contact

diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html index 962444e0..b018274e 100644 --- a/docs/pt-PT/about/index.html +++ b/docs/pt-PT/about/index.html @@ -152,7 +152,7 @@

Agradecimentos

diff --git a/docs/pt-PT/docs/index.html b/docs/pt-PT/docs/index.html index bd14ae81..90c788f2 100644 --- a/docs/pt-PT/docs/index.html +++ b/docs/pt-PT/docs/index.html @@ -76,7 +76,7 @@

Documentação

Usar o Flowlight

-

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.16.10, macOS 15 ou posterior.

+

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.16.11, macOS 15 ou posterior.

diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html index ce421d9c..4316be94 100644 --- a/docs/pt-PT/index.html +++ b/docs/pt-PT/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Veja a atividade de rede das suas apps.

brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+UniversalGPL-3.0Sem telemetria

+

v0.16.11macOS 15+UniversalGPL-3.0Sem telemetria

@@ -589,7 +589,7 @@

Saiba o que sai do seu Mac.

diff --git a/docs/pt-PT/privacy/index.html b/docs/pt-PT/privacy/index.html index fc328a4f..87f2c164 100644 --- a/docs/pt-PT/privacy/index.html +++ b/docs/pt-PT/privacy/index.html @@ -189,7 +189,7 @@

Contacto

diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html index dcb119c5..757272f4 100644 --- a/docs/pt-PT/threat-model/index.html +++ b/docs/pt-PT/threat-model/index.html @@ -222,7 +222,7 @@

Comunicar uma vulnerabilidade

diff --git a/docs/releases/index.html b/docs/releases/index.html index 4d3de96c..e5363126 100644 --- a/docs/releases/index.html +++ b/docs/releases/index.html @@ -61,7 +61,18 @@

What's new

-

0.16.10

Latest
+

0.16.11

Latest
+
    +
  • Keep every route in one visual language. Every connection is one smooth cubic curve; nearby obstacle corridors are preferred over deep canvas-edge U-turns, while card clearance, endpoint attachment, route separation, hit testing, and bounded layout remain intact.
  • +
  • Compare several endpoints at once. Source and destination selectors now support complete multi-selection with exclusive Any behavior, all-to-all evidence compatibility, blocked-only pairs, accessible selected states, and one-click Reset.
  • +
  • Show who started the starter. Capture-time launcher evidence now preserves one upstream non-shell launcher, so AI activity can truthfully show Ghostty → Herdr → Claude while Herdr remains the immediate restart target.
  • +
  • Refit each changed investigation. Endpoint scope changes discard incompatible coordinates and automatically center the new topology; Traffic and AI activity transitions show a flipping Flowlight mark until the final layout is ready instead of flashing partial geometry.
  • +
  • Make installation choices scan as one unit. Mac App Store, direct Mac download, and GitHub share one equal-height row, with the complete copyable Homebrew command directly below.
  • +
+
+ +
+

0.16.10

  • Start with the traffic that matters most. Traffic shows the ten busiest applications and AI activity shows the ten busiest proved agent roots, ranked by complete received-plus-sent traffic with deterministic ties.
  • Trace any source to any destination. Two searchable optional selectors cover every endpoint combination, mutually limit choices to pairs with observed or blocked evidence, and provide a one-click Reset while revealing the complete connected semantic neighborhood.
  • @@ -1540,7 +1551,7 @@

    What's new

diff --git a/docs/threat-model/index.html b/docs/threat-model/index.html index cc7e6f1c..a4427fa8 100644 --- a/docs/threat-model/index.html +++ b/docs/threat-model/index.html @@ -231,7 +231,7 @@

Reporting a vulnerability

diff --git a/docs/zh-Hans/about/index.html b/docs/zh-Hans/about/index.html index 1a85b2d7..7a8b32c5 100644 --- a/docs/zh-Hans/about/index.html +++ b/docs/zh-Hans/about/index.html @@ -152,7 +152,7 @@

致谢

diff --git a/docs/zh-Hans/docs/index.html b/docs/zh-Hans/docs/index.html index 283920a0..f2ec4760 100644 --- a/docs/zh-Hans/docs/index.html +++ b/docs/zh-Hans/docs/index.html @@ -76,7 +76,7 @@

文档

使用 Flowlight

-

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.16.10,macOS 15 或更高版本。

+

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.16.11,macOS 15 或更高版本。

diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html index a9421f56..e5b55d82 100644 --- a/docs/zh-Hans/index.html +++ b/docs/zh-Hans/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

看清应用的网络活动。

brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+通用架构GPL-3.0无遥测

+

v0.16.11macOS 15+通用架构GPL-3.0无遥测

@@ -586,7 +586,7 @@

知道什么离开了你的 Mac。

diff --git a/docs/zh-Hans/privacy/index.html b/docs/zh-Hans/privacy/index.html index f31d66ab..27ade96d 100644 --- a/docs/zh-Hans/privacy/index.html +++ b/docs/zh-Hans/privacy/index.html @@ -187,7 +187,7 @@

联系方式

diff --git a/docs/zh-Hans/threat-model/index.html b/docs/zh-Hans/threat-model/index.html index 98921ebf..b1834028 100644 --- a/docs/zh-Hans/threat-model/index.html +++ b/docs/zh-Hans/threat-model/index.html @@ -210,7 +210,7 @@

报告漏洞

diff --git a/docs/zh-Hant/about/index.html b/docs/zh-Hant/about/index.html index 15dbbfa6..43a2a1ed 100644 --- a/docs/zh-Hant/about/index.html +++ b/docs/zh-Hant/about/index.html @@ -152,7 +152,7 @@

致謝

diff --git a/docs/zh-Hant/docs/index.html b/docs/zh-Hant/docs/index.html index 2de5df83..8f350aad 100644 --- a/docs/zh-Hant/docs/index.html +++ b/docs/zh-Hant/docs/index.html @@ -76,7 +76,7 @@

說明文件

使用 Flowlight

-

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.16.10,macOS 15 或以上版本。

+

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.16.11,macOS 15 或以上版本。

diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html index cf85b46f..3103c511 100644 --- a/docs/zh-Hant/index.html +++ b/docs/zh-Hant/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

看見每個 App 的網路活動。
brew install --cask xinbetween/tap/flowlight

-

v0.16.10macOS 15+通用架構GPL-3.0無遙測

+

v0.16.11macOS 15+通用架構GPL-3.0無遙測

@@ -587,7 +587,7 @@

知道有什麼離開了你的 Mac。

diff --git a/docs/zh-Hant/privacy/index.html b/docs/zh-Hant/privacy/index.html index 281320a7..111d4817 100644 --- a/docs/zh-Hant/privacy/index.html +++ b/docs/zh-Hant/privacy/index.html @@ -187,7 +187,7 @@

聯絡

diff --git a/docs/zh-Hant/threat-model/index.html b/docs/zh-Hant/threat-model/index.html index ec72380f..42c7a61c 100644 --- a/docs/zh-Hant/threat-model/index.html +++ b/docs/zh-Hant/threat-model/index.html @@ -211,7 +211,7 @@

回報漏洞

diff --git a/project.yml b/project.yml index 6f473314..0ca04cff 100644 --- a/project.yml +++ b/project.yml @@ -15,8 +15,8 @@ settings: DEVELOPMENT_TEAM: "" CODE_SIGN_STYLE: Automatic ENABLE_HARDENED_RUNTIME: YES - MARKETING_VERSION: "0.16.10" - CURRENT_PROJECT_VERSION: "72" + MARKETING_VERSION: "0.16.11" + CURRENT_PROJECT_VERSION: "73" configs: AppStore: DEVELOPMENT_TEAM: 38RJUJHKZS diff --git a/site/pages/releases.html b/site/pages/releases.html index d77cfdf2..9630e75e 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -13,7 +13,18 @@

What's new

-

0.16.10

Latest
+

0.16.11

Latest
+
    +
  • Keep every route in one visual language. Every connection is one smooth cubic curve; nearby obstacle corridors are preferred over deep canvas-edge U-turns, while card clearance, endpoint attachment, route separation, hit testing, and bounded layout remain intact.
  • +
  • Compare several endpoints at once. Source and destination selectors now support complete multi-selection with exclusive Any behavior, all-to-all evidence compatibility, blocked-only pairs, accessible selected states, and one-click Reset.
  • +
  • Show who started the starter. Capture-time launcher evidence now preserves one upstream non-shell launcher, so AI activity can truthfully show Ghostty → Herdr → Claude while Herdr remains the immediate restart target.
  • +
  • Refit each changed investigation. Endpoint scope changes discard incompatible coordinates and automatically center the new topology; Traffic and AI activity transitions show a flipping Flowlight mark until the final layout is ready instead of flashing partial geometry.
  • +
  • Make installation choices scan as one unit. Mac App Store, direct Mac download, and GitHub share one equal-height row, with the complete copyable Homebrew command directly below.
  • +
+
+ +
+

0.16.10

  • Start with the traffic that matters most. Traffic shows the ten busiest applications and AI activity shows the ten busiest proved agent roots, ranked by complete received-plus-sent traffic with deterministic ties.
  • Trace any source to any destination. Two searchable optional selectors cover every endpoint combination, mutually limit choices to pairs with observed or blocked evidence, and provide a one-click Reset while revealing the complete connected semantic neighborhood.
  • From 605d45e1077a22c83760b1689b64aaaccb9de5dd Mon Sep 17 00:00:00 2001 From: blessdyb Date: Tue, 3 Nov 2026 21:17:09 -0800 Subject: [PATCH 2/2] Keep routing factors compatible with older Swift Make the fractional CGFloat explicit so the Store graph compiles on every supported Xcode version without changing route geometry. Co-Authored-By: Claude Code --- Flowlight/UI/Visualization/TrafficGraphLayout.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Flowlight/UI/Visualization/TrafficGraphLayout.swift b/Flowlight/UI/Visualization/TrafficGraphLayout.swift index 3055ba3c..e648dcc4 100644 --- a/Flowlight/UI/Visualization/TrafficGraphLayout.swift +++ b/Flowlight/UI/Visualization/TrafficGraphLayout.swift @@ -982,7 +982,7 @@ enum TrafficGraphLayout { let orderedProfiles = denseSearch ? Array(orderedSmoothProfiles.prefix(1)) : orderedSmoothProfiles + Array(controlProfiles.dropFirst(3)) - let controlFactors: [CGFloat] = denseSearch ? [1, 2.6] : [1, 4 / 3, 2, 2.6, 3.2] + let controlFactors: [CGFloat] = denseSearch ? [1, 2.6] : [1, CGFloat(4.0 / 3.0), 2, 2.6, 3.2] func staysInsideCanvas(_ candidate: TrafficGraphCurve) -> Bool { (0...64).allSatisfy { step in let point = candidate.point(at: CGFloat(step) / 64)