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 ,
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.16.10 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.16.11 · Not affiliated with Apple or any AI provider named on this site.
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
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.16.10 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.16.11 · Not affiliated with Apple or any AI provider named on this site.
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
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.16.10 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.16.11 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
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.
@@ -763,7 +763,7 @@ Grenzen
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.16.10 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.16.11 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
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 @@
-
+
Zum Inhalt springen
@@ -97,7 +97,7 @@ Sieh, was deine Apps im Netz tun.brew install --cask xinbetween/tap/flowlightKopieren
- v0.16.10 macOS 15+ Universal GPL-3.0 Keine Telemetrie
+ v0.16.11 macOS 15+ Universal GPL-3.0 Keine Telemetrie
@@ -589,7 +589,7 @@ Wisse, was deinen Mac verlässt.
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.16.10 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.16.11 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
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 @@
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.16.10 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.16.11 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
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
© 2026 Die Flowlight-Mitwirkenden. Flowlight ist freie Software, veröffentlicht unter der GNU General Public License v3.0 .
-
Version 0.16.10 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
+
Version 0.16.11 · Nicht verbunden mit Apple oder einem der hier genannten KI-Anbieter.
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.
@@ -812,7 +812,7 @@ Limitations
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.16.10 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.16.11 · Not affiliated with Apple or any AI provider named on this site.
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
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.16.10 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.16.11 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
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.
@@ -756,7 +756,7 @@ Limitaciones
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.16.10 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.16.11 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
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 @@
-
+
Ir al contenido
@@ -97,7 +97,7 @@ Observa la actividad de red de tus apps.
brew install --cask xinbetween/tap/flowlightCopiar
- v0.16.10 macOS 15+ Universal GPL-3.0 Sin telemetría
+ v0.16.11 macOS 15+ Universal GPL-3.0 Sin telemetría
@@ -589,7 +589,7 @@ Ten claro qué sale de tu Mac.
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.16.10 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.16.11 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
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 @@
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.16.10 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.16.11 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
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
© 2026 Quienes contribuyen a Flowlight. Flowlight es software libre, publicado bajo la GNU General Public License v3.0 .
-
Versión 0.16.10 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
+
Versión 0.16.11 · Sin relación con Apple ni con ningún proveedor de IA mencionado en este sitio.
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
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.16.10 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.16.11 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
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.
@@ -762,7 +762,7 @@ Limites
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.16.10 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.16.11 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
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 @@
-
+
Aller au contenu
@@ -97,7 +97,7 @@ Voyez l’activité réseau de vos apps.
brew install --cask xinbetween/tap/flowlightCopier
- v0.16.10 macOS 15+ Universel GPL-3.0 Sans télémétrie
+ v0.16.11 macOS 15+ Universel GPL-3.0 Sans télémétrie
@@ -589,7 +589,7 @@ Sachez ce qui quitte votre Mac.
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.16.10 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.16.11 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
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 @@
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.16.10 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.16.11 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
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é
© 2026 Les contributeurs de Flowlight. Flowlight est un logiciel libre, publié sous la GNU General Public License v3.0 .
-
Version 0.16.10 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
+
Version 0.16.11 · Sans lien avec Apple ni avec aucun fournisseur d’IA cité sur ce site.
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 @@
-
+
Skip to content
@@ -97,7 +97,7 @@ See your apps' network activity.brew install --cask xinbetween/tap/flowlightCopy
- v0.16.10 macOS 15+ Universal GPL-3.0 No telemetry
+ v0.16.11 macOS 15+ Universal GPL-3.0 No telemetry
@@ -612,7 +612,7 @@ Know what leaves your Mac.
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.16.10 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.16.11 · Not affiliated with Apple or any AI provider named on this site.
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
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.16.10 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.16.11 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
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.
@@ -760,7 +760,7 @@ Limitazioni
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.16.10 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.16.11 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
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 @@
-
+
Vai al contenuto
@@ -97,7 +97,7 @@ Vedi la rete delle tue app.Ca
brew install --cask xinbetween/tap/flowlightCopia
- v0.16.10 macOS 15+ Universale GPL-3.0 Nessuna telemetria
+ v0.16.11 macOS 15+ Universale GPL-3.0 Nessuna telemetria
@@ -589,7 +589,7 @@ Sappi che cosa esce dal tuo Mac.
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.16.10 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.16.11 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
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 @@
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.16.10 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.16.11 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
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à
© 2026 Chi contribuisce a Flowlight. Flowlight è software libero, distribuito sotto la GNU General Public License v3.0 .
-
Versione 0.16.10 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
+
Versione 0.16.11 · Non affiliato ad Apple né ad alcun fornitore di IA citato in questo sito.
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 @@ 謝辞
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.16.10 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.16.11 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
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 以降。
@@ -710,7 +710,7 @@ 制限事項
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.16.10 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.16.11 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
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.10 macOS 15+ ユニバーサル GPL-3.0 テレメトリなし
+ v0.16.11 macOS 15+ ユニバーサル GPL-3.0 テレメトリなし
@@ -588,7 +588,7 @@ Mac から何が出ていくのかを知る。
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.16.10 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.16.11 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
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 @@
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.16.10 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.16.11 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
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 @@ 脆弱性を報告する
© 2026 Flowlight コントリビューター。Flowlight は GNU General Public License v3.0 のもとで公開されている自由ソフトウェアです。
-
バージョン 0.16.10 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
+
バージョン 0.16.11 · Apple および本サイトに挙げた AI プロバイダとは関係ありません。
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 @@ 감사
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.16.10 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.16.11 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
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 이상.
@@ -750,7 +750,7 @@ 한계
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.16.10 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.16.11 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
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.10 macOS 15+ 유니버설 GPL-3.0 텔레메트리 없음
+ v0.16.11 macOS 15+ 유니버설 GPL-3.0 텔레메트리 없음
@@ -589,7 +589,7 @@ 내 Mac에서 무엇이 나가는지 아세요.
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.16.10 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.16.11 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
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 @@
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.16.10 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.16.11 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
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 @@ 취약점 신고하기
© 2026 Flowlight 기여자들. Flowlight는 GNU General Public License v3.0 아래 배포되는 자유 소프트웨어입니다.
-
버전 0.16.10 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
+
버전 0.16.11 · Apple 및 이 사이트에 언급된 어떤 AI 제공업체와도 무관합니다.
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
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.16.10 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.16.11 · Not affiliated with Apple or any AI provider named on this site.
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 @@
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.16.10 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.16.11 · Not affiliated with Apple or any AI provider named on this site.
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
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.16.10 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.16.11 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
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.
@@ -756,7 +756,7 @@ Limitações
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.16.10 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.16.11 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
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 @@
-
+
Ir para o conteúdo
@@ -97,7 +97,7 @@ Veja a atividade de rede das suas apps.
brew install --cask xinbetween/tap/flowlightCopiar
- v0.16.10 macOS 15+ Universal GPL-3.0 Sem telemetria
+ v0.16.11 macOS 15+ Universal GPL-3.0 Sem telemetria
@@ -589,7 +589,7 @@ Saiba o que sai do seu Mac.
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.16.10 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.16.11 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
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 @@
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.16.10 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.16.11 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
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
© 2026 Quem contribui para o Flowlight. O Flowlight é software livre, publicado sob a GNU General Public License v3.0 .
-
Versão 0.16.10 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
+
Versão 0.16.11 · Sem qualquer ligação à Apple ou a algum fornecedor de IA mencionado neste site.
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 November 3, 2026 Latest
+ 0.16.11 November 3, 2026 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.
+
+
+
+
+
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
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.16.10 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.16.11 · Not affiliated with Apple or any AI provider named on this site.
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
© 2026 The Flowlight contributors. Flowlight is free software, released under the
GNU General Public License v3.0 .
-
Version 0.16.10 · Not affiliated with Apple or any AI provider named on this site.
+
Version 0.16.11 · Not affiliated with Apple or any AI provider named on this site.
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 @@ 致谢
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.16.10 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.16.11 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
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 或更高版本。
@@ -714,7 +714,7 @@ 限制
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.16.10 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.16.11 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
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.10 macOS 15+ 通用架构 GPL-3.0 无遥测
+ v0.16.11 macOS 15+ 通用架构 GPL-3.0 无遥测
@@ -586,7 +586,7 @@ 知道什么离开了你的 Mac。
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.16.10 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.16.11 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
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 @@
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.16.10 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.16.11 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
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 @@ 报告漏洞
© 2026 Flowlight 贡献者。Flowlight 是自由软件,依 GNU General Public License v3.0 发布。
-
版本 0.16.10 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
+
版本 0.16.11 · 与 Apple 及本站提及的任何 AI 提供方均无关联。
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 @@ 致謝
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.16.10 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.16.11 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
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 或以上版本。
@@ -719,7 +719,7 @@ 限制
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.16.10 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.16.11 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
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.10 macOS 15+ 通用架構 GPL-3.0 無遙測
+ v0.16.11 macOS 15+ 通用架構 GPL-3.0 無遙測
@@ -587,7 +587,7 @@ 知道有什麼離開了你的 Mac。
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.16.10 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.16.11 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
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 @@
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.16.10 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.16.11 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
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 @@ 回報漏洞
© 2026 Flowlight 貢獻者。Flowlight 是自由軟體,依 GNU General Public License v3.0 發布。
-
版本 0.16.10 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
+
版本 0.16.11 · 與 Apple 及本站提及的任何 AI 供應商均無關聯。
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 November 3, 2026 Latest
+ 0.16.11 November 3, 2026 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.
+
+
+
+
+
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)