From e2ade193026c1d40c030baf3b1f56a16c8792373 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Sat, 17 Oct 2026 22:46:52 -0700
Subject: [PATCH] Add managed ad and tracker rule packs
Co-Authored-By: Claude Code
---
Flowlight/Analysis/BuiltInRulePacks.swift | 49 ++
Flowlight/Analysis/RulePackStore.swift | 198 ++++++++
Flowlight/App/TrafficMonitor.swift | 48 ++
.../Capture/ExtensionTrafficSource.swift | 168 ++++++-
Flowlight/Capture/TrafficSource.swift | 4 +
Flowlight/Info.plist | 2 +-
.../Localization/de.lproj/Localizable.strings | 45 +-
.../Localization/en.lproj/Localizable.strings | 45 +-
.../Localization/es.lproj/Localizable.strings | 45 +-
.../Localization/fr.lproj/Localizable.strings | 45 +-
.../Localization/it.lproj/Localizable.strings | 45 +-
.../Localization/ja.lproj/Localizable.strings | 45 +-
.../Localization/ko.lproj/Localizable.strings | 45 +-
.../pt-PT.lproj/Localizable.strings | 45 +-
.../zh-Hans.lproj/Localizable.strings | 45 +-
.../zh-Hant.lproj/Localizable.strings | 45 +-
Flowlight/Storage/RulePackDeliveryStats.swift | 6 +
Flowlight/Storage/TrafficDatabase.swift | 212 ++++++++-
Flowlight/UI/RulePacksView.swift | 369 +++++++++++++++
Flowlight/UI/RulesView.swift | 63 ++-
Flowlight/UI/SharedStrings.swift | 14 +
FlowlightExtension/BlockEnforcer.swift | 72 ++-
FlowlightExtension/IPCServer.swift | 194 +++++++-
FlowlightExtension/Info.plist | 8 +-
FlowlightTests/BlockingTests.swift | 4 +
FlowlightTests/RulePackTests.swift | 429 ++++++++++++++++++
README.md | 21 +-
Shared/AgentPolicy.swift | 14 +-
Shared/BlockRules.swift | 15 +-
Shared/Classification/DNSParser.swift | 4 +-
Shared/Classification/HTTPHostParser.swift | 2 +-
Shared/Classification/TLSSNIParser.swift | 4 +-
Shared/GlobMatch.swift | 13 +-
Shared/HostCanonicalizer.swift | 50 ++
Shared/IPCProtocol.swift | 12 +
Shared/RulePack.swift | 341 ++++++++++++++
Shared/RulePackSimulation.swift | 67 +++
Shared/RulePackTransfer.swift | 95 ++++
docs/docs/index.html | 32 +-
docs/index.html | 16 +-
docs/llms-full.txt | 52 ++-
docs/sitemap.xml | 4 +-
site/pages/docs.html | 30 +-
site/pages/index.html | 14 +-
44 files changed, 2999 insertions(+), 77 deletions(-)
create mode 100644 Flowlight/Analysis/BuiltInRulePacks.swift
create mode 100644 Flowlight/Analysis/RulePackStore.swift
create mode 100644 Flowlight/Storage/RulePackDeliveryStats.swift
create mode 100644 Flowlight/UI/RulePacksView.swift
create mode 100644 FlowlightTests/RulePackTests.swift
create mode 100644 Shared/HostCanonicalizer.swift
create mode 100644 Shared/RulePack.swift
create mode 100644 Shared/RulePackSimulation.swift
create mode 100644 Shared/RulePackTransfer.swift
diff --git a/Flowlight/Analysis/BuiltInRulePacks.swift b/Flowlight/Analysis/BuiltInRulePacks.swift
new file mode 100644
index 00000000..24627b2b
--- /dev/null
+++ b/Flowlight/Analysis/BuiltInRulePacks.swift
@@ -0,0 +1,49 @@
+import Foundation
+
+/// Official lists shipped inside the signed app. They are deliberately small and reviewed in 0.15.0; later remote
+/// subscriptions use the same manifest/snapshot boundary but do not change what these built-ins mean.
+enum BuiltInRulePacks {
+ static let all: [RulePackDefinition] = [advertising, tracking, telemetry]
+
+ static let advertising = definition(
+ id: "official.advertising",
+ title: "Advertising",
+ summary: "Known advertising delivery and auction hosts across apps.",
+ caveat: "Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app.",
+ category: .advertising,
+ hosts: [
+ "adnxs.com", "adsrvr.org", "adservice.google.com", "casalemedia.com", "criteo.com", "criteo.net",
+ "doubleclick.net", "googleadservices.com", "googlesyndication.com", "openx.net", "pubmatic.com",
+ "rubiconproject.com",
+ ])
+
+ static let tracking = definition(
+ id: "official.tracking",
+ title: "Cross-site tracking",
+ summary: "Common analytics, session replay, and cross-site measurement hosts.",
+ caveat: "Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable.",
+ category: .tracking,
+ hosts: RuleTemplate.analyticsHosts)
+
+ static let telemetry = definition(
+ id: "official.telemetry",
+ title: "Privacy and telemetry",
+ summary: "Crash reporting, install attribution, and audited Apple advertising and analytics hosts.",
+ caveat: "Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed.",
+ category: .telemetry,
+ hosts: RuleTemplate.crashReportingHosts + RuleTemplate.attributionHosts + RuleTemplate.appleTelemetryHosts)
+
+ private static func definition(id: String, title: String, summary: String, caveat: String,
+ category: RulePackEntry.Category, hosts: [String]) -> RulePackDefinition {
+ let canonical = Array(Set(hosts.compactMap(HostCanonicalizer.host))).sorted()
+ let entries = canonical.enumerated().map {
+ RulePackEntry(id: UInt32($0.offset + 1), host: $0.element, match: .suffix, category: category)
+ }
+ let manifest = RulePackManifest(
+ id: id, version: "1", generatedAt: Date(timeIntervalSince1970: 1_792_220_400),
+ entryCount: entries.count, contentSHA256: RulePackValidator.digest(entries),
+ titleKey: title, summaryKey: summary, caveatKey: caveat,
+ source: "Flowlight curated built-in list", license: "GPL-3.0")
+ return RulePackDefinition(manifest: manifest, entries: entries)
+ }
+}
diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift
new file mode 100644
index 00000000..cd95e6c0
--- /dev/null
+++ b/Flowlight/Analysis/RulePackStore.swift
@@ -0,0 +1,198 @@
+import Foundation
+
+/// The app-owned half of managed rule packs: built-in definitions, user activation/scope, exceptions, and the
+/// complete validated snapshot handed to the Network Extension.
+@MainActor
+final class RulePackStore: ObservableObject {
+ enum ApplyStatus: Equatable, Sendable {
+ case off, ready, applying, enforcing, watchingOnly, incompatible(String), failed(String)
+ }
+
+ @Published private(set) var configurations: [RulePackConfiguration] = []
+ @Published private(set) var overrides: [RulePackOverride] = []
+ @Published private(set) var events: [RulePackEventRecord] = []
+ @Published private(set) var deliveryStats = RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0)
+ @Published private(set) var status: ApplyStatus = .off
+ @Published private(set) var version = 0
+
+ let definitions: [RulePackDefinition]
+ var onChange: (RulePackSnapshot?) -> Void = { _ in }
+ private weak var db: TrafficDatabase?
+ private var definitionsValid = true
+
+ init(definitions: [RulePackDefinition] = BuiltInRulePacks.all) {
+ self.definitions = definitions
+ do { try RulePackValidator.validate(definitions) }
+ catch {
+ definitionsValid = false
+ status = .failed("Built-in pack validation failed: \(error)")
+ }
+ }
+
+ var enabledCount: Int { configurations.filter(\.enabled).count }
+ var hasEnabledPacks: Bool { enabledCount > 0 }
+
+ func configuration(for packID: String) -> RulePackConfiguration {
+ configurations.first { $0.packID == packID } ?? RulePackConfiguration(packID: packID)
+ }
+
+ func overrides(for packID: String) -> [RulePackOverride] {
+ overrides.filter { $0.packID == packID }
+ }
+
+ func attach(db: TrafficDatabase) {
+ self.db = db
+ reload()
+ }
+
+ func reload() {
+ guard let db else { return }
+ db.async { [weak self] db in
+ let saved = try db.loadRulePackConfigurations()
+ let exceptions = try db.loadRulePackOverrides()
+ let events = try db.rulePackEvents(limit: 300)
+ let deliveryStats = try db.rulePackDeliveryStats()
+ Task { @MainActor in
+ guard let self else { return }
+ let known = Set(self.definitions.map(\.id))
+ self.configurations = self.definitions.map { definition in
+ guard var configuration = saved.first(where: { $0.packID == definition.id }) else {
+ return RulePackConfiguration(packID: definition.id)
+ }
+ if configuration.enabled, !configuration.isConsented(to: definition) {
+ configuration.enabled = false
+ }
+ return configuration
+ }
+ self.overrides = exceptions.filter { known.contains($0.packID) }
+ self.events = events.filter { known.contains($0.packID) }
+ self.deliveryStats = deliveryStats
+ self.publish()
+ }
+ }
+ }
+
+ func save(_ configuration: RulePackConfiguration) {
+ guard definitions.contains(where: { $0.id == configuration.packID }) else { return }
+ var cleaned = configuration
+ if cleaned.enabled, let definition = definitions.first(where: { $0.id == cleaned.packID }) {
+ cleaned.consentedVersion = definition.manifest.version
+ cleaned.consentedContentSHA256 = definition.manifest.contentSHA256
+ }
+ cleaned.apps = Array(Set(configuration.apps.map {
+ $0.trimmingCharacters(in: .whitespacesAndNewlines)
+ }.filter { !$0.isEmpty })).sorted()
+ if cleaned.scope == .selectedApps && cleaned.apps.isEmpty { cleaned.enabled = false }
+ persist({ try $0.saveRulePackConfiguration(cleaned) }) { [weak self] in
+ guard let self else { return }
+ if let index = self.configurations.firstIndex(where: { $0.packID == cleaned.packID }) {
+ self.configurations[index] = cleaned
+ } else {
+ self.configurations.append(cleaned)
+ }
+ self.publish()
+ }
+ }
+
+ func setEnabled(_ packID: String, _ enabled: Bool) {
+ var value = configuration(for: packID)
+ value.enabled = enabled
+ save(value)
+ }
+
+ func addOverride(_ override: RulePackOverride) {
+ guard definitions.contains(where: { $0.id == override.packID }),
+ let host = HostCanonicalizer.host(override.host) else { return }
+ var cleaned = override
+ cleaned.host = host
+ cleaned.app = override.app.trimmingCharacters(in: .whitespacesAndNewlines)
+ persist({ try $0.saveRulePackOverride(cleaned) }) { [weak self] in
+ guard let self else { return }
+ self.overrides.removeAll { $0.id == cleaned.id }
+ self.overrides.append(cleaned)
+ self.publish()
+ }
+ }
+
+ func deleteOverride(_ override: RulePackOverride) {
+ persist({ try $0.deleteRulePackOverride(override.id) }) { [weak self] in
+ self?.overrides.removeAll { $0.id == override.id }
+ self?.publish()
+ }
+ }
+
+ func updateStatus(_ newStatus: ApplyStatus) { status = newStatus }
+
+ func markApplying() {
+ guard definitionsValid else { return }
+ status = .applying
+ }
+
+ func record(_ batch: RulePackEventBatch, completion: @escaping (Bool) -> Void) {
+ guard let db else { completion(false); return }
+ db.queue.async { [weak self, weak db] in
+ guard let db else { completion(false); return }
+ do {
+ _ = try db.recordRulePackEventBatch(batch)
+ let events = try db.rulePackEvents(limit: 300)
+ let deliveryStats = try db.rulePackDeliveryStats()
+ Task { @MainActor in
+ self?.events = events
+ self?.deliveryStats = deliveryStats
+ self?.version += 1
+ completion(true)
+ }
+ } catch {
+ appLog.error("rule pack batch failed: \(String(describing: error), privacy: .public)")
+ Task { @MainActor in completion(false) }
+ }
+ }
+ }
+
+ func events(for packID: String) -> [RulePackEventRecord] { events.filter { $0.packID == packID } }
+
+ func clearActivity() {
+ events = []
+ deliveryStats = RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0)
+ version += 1
+ }
+
+ var snapshot: RulePackSnapshot? {
+ let definitionsByID = Dictionary(uniqueKeysWithValues: definitions.map { ($0.id, $0) })
+ let active = configurations.filter { configuration in
+ configuration.enabled
+ && definitionsByID[configuration.packID].map(configuration.isConsented(to:)) == true
+ }
+ guard !active.isEmpty else { return nil }
+ let packIDs = Set(active.map(\.packID))
+ let included = definitions.filter { packIDs.contains($0.id) }
+ let includedOverrides = overrides.filter { packIDs.contains($0.packID) }
+ return RulePackSnapshot.make(definitions: included, configurations: active, overrides: includedOverrides)
+ }
+
+ private func persist(_ write: @escaping (TrafficDatabase) throws -> Void,
+ then commit: @escaping @MainActor () -> Void) {
+ guard let db else {
+ status = .failed("Rule pack storage is unavailable.")
+ return
+ }
+ db.queue.async { [weak self, weak db] in
+ guard let db else { return }
+ do {
+ try write(db)
+ Task { @MainActor in commit() }
+ } catch {
+ Task { @MainActor in
+ self?.status = .failed("Could not save the rule pack change: \(error.localizedDescription)")
+ }
+ }
+ }
+ }
+
+ private func publish() {
+ version += 1
+ guard definitionsValid else { return }
+ status = hasEnabledPacks ? .ready : .off
+ onChange(snapshot)
+ }
+}
diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift
index 47471ae7..99063aa6 100644
--- a/Flowlight/App/TrafficMonitor.swift
+++ b/Flowlight/App/TrafficMonitor.swift
@@ -54,6 +54,8 @@ final class TrafficMonitor: ObservableObject {
let exporter = ExportController()
/// Everything Flowlight has been told to block or allow, and what those rules have done.
let rules = RuleStore()
+ /// Official managed destination lists, kept separate from human-authored rules and disabled by default.
+ let rulePacks = RulePackStore()
/// Which tools each agent may use, which is a different question from which hosts it may reach.
let guardrails = GuardrailStore()
/// Advisory inspection plugins: official built-ins first, installed third-party packs later.
@@ -153,6 +155,13 @@ final class TrafficMonitor: ObservableObject {
Task { await localRisk.recover() }
rules.onChange = { [weak self] set in self?.source?.setRules(set) }
rules.attach(db: db)
+ rulePacks.onChange = { [weak self] snapshot in
+ guard let self else { return }
+ if self.source is ExtensionTrafficSource { self.rulePacks.markApplying() }
+ else { self.rulePacks.updateStatus(self.rulePacks.hasEnabledPacks ? .watchingOnly : .off) }
+ self.source?.setRulePacks(snapshot)
+ }
+ rulePacks.attach(db: db)
// A rule that names a path is carried out by the proxy, which reads its list fresh on every connection.
inspection.requestRules = { [live = rules.live] in live.requestRules() }
inspection.onRuleRefusal = { [weak self] event in
@@ -365,6 +374,12 @@ final class TrafficMonitor: ObservableObject {
extensionSource.onRuleDecisions = { [weak self] events in
Task { @MainActor in self?.rules.record(events) }
}
+ extensionSource.onRulePackEvents = { [weak self] batch, completion in
+ self?.recordRulePackEvents(batch, completion: completion) ?? completion(false)
+ }
+ extensionSource.onRulePackStatus = { [weak self] result in
+ Task { @MainActor in self?.updateRulePackStatus(result) }
+ }
extensionSource.onRepairRequested = { [weak self] in
Task { @MainActor in self?.repairExtensionVersion() }
}
@@ -382,6 +397,35 @@ final class TrafficMonitor: ObservableObject {
})
pushEnforcement()
newSource.setRules(rules.ruleSet)
+ newSource.setRulePacks(rulePacks.snapshot)
+ if rulePacks.hasEnabledPacks, !(newSource is ExtensionTrafficSource) {
+ rulePacks.updateStatus(.watchingOnly)
+ }
+ }
+
+ private func updateRulePackStatus(_ result: RulePackApplyResult) {
+ switch result.status {
+ case .accepted:
+ rulePacks.updateStatus(.applying)
+ case .committed:
+ rulePacks.updateStatus(.enforcing)
+ case .cleared:
+ rulePacks.updateStatus(.off)
+ case .incompatible:
+ rulePacks.updateStatus(.incompatible(result.detail))
+ case .invalid, .stale:
+ rulePacks.updateStatus(.failed(result.detail))
+ }
+ }
+
+ nonisolated private func recordRulePackEvents(_ batch: RulePackEventBatch,
+ completion: @escaping (Bool) -> Void) {
+ Task { @MainActor in
+ self.rulePacks.record(batch) { [weak self] committed in
+ if committed { self?.dataVersion += 1 }
+ completion(committed)
+ }
+ }
}
/// The extension source has been refused often enough that plain redialling isn't the answer. Ask macOS which
@@ -610,6 +654,9 @@ final class TrafficMonitor: ObservableObject {
for hour in completed.completedHours { try engine.hourCompleted(hour) }
for day in completed.completedDays { try engine.dayCompleted(day) }
try engine.evaluateIdleTraffic()
+ let eventCutoff = Date().addingTimeInterval(-max(1, retentionHours) * 3600)
+ try db.pruneRuleEvents(before: eventCutoff)
+ try db.pruneRulePackEvents(before: eventCutoff)
let coverage = try db.coverage(since: Date().addingTimeInterval(-3600))
Task { @MainActor in
self?.coverage = coverage
@@ -663,6 +710,7 @@ final class TrafficMonitor: ObservableObject {
self?.perSecond.removeAll()
self?.sessionIn = 0
self?.sessionOut = 0
+ self?.rulePacks.clearActivity()
self?.dataVersion += 1
self?.refreshAlertCount()
}
diff --git a/Flowlight/Capture/ExtensionTrafficSource.swift b/Flowlight/Capture/ExtensionTrafficSource.swift
index a0833865..7b9fad4f 100644
--- a/Flowlight/Capture/ExtensionTrafficSource.swift
+++ b/Flowlight/Capture/ExtensionTrafficSource.swift
@@ -27,6 +27,12 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
private var ruleSet = RuleSet()
/// Connections a rule decided, refusals and relaxations alike.
var onRuleDecisions: (([RuleEvent]) -> Void)?
+ /// Aggregated managed-pack decisions. These never enter the ordinary block-alert path.
+ var onRulePackEvents: ((RulePackEventBatch, @escaping (Bool) -> Void) -> Void)?
+ var onRulePackStatus: ((RulePackApplyResult) -> Void)?
+ private var rulePackSnapshot: RulePackSnapshot?
+ /// Invalidates every callback from a superseded desired snapshot or XPC connection.
+ private var rulePackPushEpoch: UInt64 = 0
/// The escalation ladder for a connection that keeps failing. Only touched on the main thread, like
/// `connection`, because a decision made from two queues at once is no decision at all.
private var recovery = ExtensionRecovery()
@@ -72,6 +78,7 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
let superseded = connection
connection = nil
superseded?.invalidate()
+ rulePackPushEpoch &+= 1
let connection = NSXPCConnection(machServiceName: FlowlightConstants.machServiceName, options: [])
connection.remoteObjectInterface = NSXPCInterface(with: FlowlightProviderXPC.self)
@@ -102,8 +109,14 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
self.status?(self.sawTraffic ? L("Connected to filter extension %@", version)
: L("Connected to filter extension %@ — no traffic from it yet", version))
self.startHeartbeat()
- self.pushEnforcement()
- self.pushRules()
+ self.reportPackStatus(.init(status: .accepted, detail: "Synchronizing managed packs"))
+ // Initial synchronization is ordered: explicit user allows must exist before either managed packs or
+ // AgentPolicy can block. The extension stays fail-open until the final pack commit/clear acknowledges.
+ self.pushRules {
+ self.pushEnforcement {
+ self.pushRulePacks()
+ }
+ }
self.checkFilterState(on: connection, version: version)
}
}
@@ -200,11 +213,16 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
/// Always on the main thread, like `connect()`, and always the whole list: the extension holds no state of its
/// own about this, so one message either describes the current rules completely or turns blocking off.
- private func pushEnforcement() {
+ private func pushEnforcement(completion: (() -> Void)? = nil) {
guard let connection else { return }
let payload = enforcement.isEmpty ? Data() : BlockCoding.encode(enforcement)
let proxy = connection.remoteObjectProxyWithErrorHandler { _ in } as? FlowlightProviderXPC
- proxy?.setEnforcement(payload: payload) { _ in }
+ proxy?.setEnforcement(payload: payload) { _ in
+ DispatchQueue.main.async { [weak self, weak connection] in
+ guard let self, let connection, self.connection === connection else { return }
+ completion?()
+ }
+ }
}
func setRules(_ rules: RuleSet) {
@@ -215,11 +233,149 @@ final class ExtensionTrafficSource: NSObject, TrafficSource, FlowlightAppXPC, @u
}
}
- private func pushRules() {
+ private func pushRules(completion: (() -> Void)? = nil) {
guard let connection else { return }
let payload = ruleSet.rules.isEmpty && ruleSet.pausedUntil == nil ? Data() : BlockCoding.encode(ruleSet)
let proxy = connection.remoteObjectProxyWithErrorHandler { _ in } as? FlowlightProviderXPC
- proxy?.setRules(payload: payload) { _ in }
+ proxy?.setRules(payload: payload) { _ in
+ DispatchQueue.main.async { [weak self, weak connection] in
+ guard let self, let connection, self.connection === connection else { return }
+ completion?()
+ }
+ }
+ }
+
+ func setRulePacks(_ snapshot: RulePackSnapshot?) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.rulePackSnapshot = snapshot
+ self.rulePackPushEpoch &+= 1
+ self.pushRulePacks()
+ }
+ }
+
+ private func pushRulePacks() {
+ guard let connection else { return }
+ let epoch = rulePackPushEpoch
+ let desiredGeneration = rulePackSnapshot?.generation
+ let proxy = connection.remoteObjectProxyWithErrorHandler { [weak self, weak connection] error in
+ DispatchQueue.main.async {
+ guard let self, let connection, self.connection === connection,
+ self.rulePackPushEpoch == epoch else { return }
+ self.reportPackStatus(.init(status: .invalid, detail: error.localizedDescription))
+ }
+ } as? FlowlightProviderXPC
+ proxy?.rulePackHello(appProtocol: Int(RulePackSnapshot.protocolVersion)) { [weak self, weak connection] data in
+ DispatchQueue.main.async {
+ guard let self, let connection, self.connection === connection,
+ self.rulePackPushEpoch == epoch,
+ self.rulePackSnapshot?.generation == desiredGeneration,
+ let handshake = BlockCoding.decode(RulePackHandshake.self, from: data) else { return }
+ guard handshake.protocolVersion == RulePackSnapshot.protocolVersion else {
+ self.reportPackStatus(.init(status: .incompatible, activeDigest: handshake.activeDigest,
+ detail: "The installed extension does not support this pack format."))
+ return
+ }
+ guard let snapshot = self.rulePackSnapshot else {
+ let request = RulePackClearRequest(expectedActiveGeneration: handshake.activeGeneration)
+ proxy?.clearRulePacks(payload: BlockCoding.encode(request)) { [weak self, weak connection] data in
+ DispatchQueue.main.async {
+ guard let self, let connection, self.connection === connection,
+ self.rulePackPushEpoch == epoch, self.rulePackSnapshot == nil else { return }
+ self.decodePackStatus(data)
+ }
+ }
+ return
+ }
+ guard handshake.activeDigest != snapshot.digest else {
+ self.reportPackStatus(.init(status: .committed, generation: handshake.activeGeneration,
+ activeDigest: handshake.activeDigest,
+ activePacks: snapshot.configurations.filter(\.enabled).count))
+ return
+ }
+ let payload = BlockCoding.encode(snapshot)
+ let chunkSize = 64 * 1_024
+ let chunks = stride(from: 0, to: payload.count, by: chunkSize).map {
+ payload.subdata(in: $0.. Void) {
+ guard let batch = BlockCoding.decode(RulePackEventBatch.self, from: payload),
+ let onRulePackEvents else {
+ reply(false)
+ return
+ }
+ onRulePackEvents(batch, reply)
}
func ruleDecisions(payload: Data, reply: @escaping () -> Void) {
diff --git a/Flowlight/Capture/TrafficSource.swift b/Flowlight/Capture/TrafficSource.swift
index daa0c995..4b7f190f 100644
--- a/Flowlight/Capture/TrafficSource.swift
+++ b/Flowlight/Capture/TrafficSource.swift
@@ -12,11 +12,15 @@ protocol TrafficSource: AnyObject {
/// The rule list this source should carry out. Same story as `setEnforcement`: only the Network Extension is
/// in the data path, and the Rules screen says so per rule rather than pretending.
func setRules(_ rules: RuleSet)
+ /// One complete managed-pack generation. Nil clears packs. Other capture sources ignore it and the UI reports
+ /// watching-only instead of claiming that the setting is enforced.
+ func setRulePacks(_ snapshot: RulePackSnapshot?)
}
extension TrafficSource {
func setEnforcement(_ policies: [AgentPolicy]) {}
func setRules(_ rules: RuleSet) {}
+ func setRulePacks(_ snapshot: RulePackSnapshot?) {}
}
enum CaptureMode: String, CaseIterable, Identifiable {
diff --git a/Flowlight/Info.plist b/Flowlight/Info.plist
index b976fb48..4d98968b 100644
--- a/Flowlight/Info.plist
+++ b/Flowlight/Info.plist
@@ -25,7 +25,7 @@
LSMinimumSystemVersion$(MACOSX_DEPLOYMENT_TARGET)NSSystemExtensionUsageDescription
- Flowlight installs a network filter extension so it can attribute every connection to the app that made it. It records only metadata, never blocks traffic, and nothing leaves your Mac.
+ Flowlight installs a network filter extension to attribute connections to apps and can refuse only the rules and managed packs you enable. It records metadata locally, and nothing leaves your Mac.NSDesktopFolderUsageDescriptionFlowlight looks for AI agent configuration (skills, MCP servers, hooks) in the project folder you pick, so it can show what each agent is set up to do. It reads only those config files, and nothing leaves your Mac.NSDocumentsFolderUsageDescription
diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings
index 4b83817f..44fd7ca6 100644
--- a/Flowlight/Localization/de.lproj/Localizable.strings
+++ b/Flowlight/Localization/de.lproj/Localizable.strings
@@ -1157,7 +1157,42 @@
"and %lld more" = "und %lld weitere";
"Tested against traffic back to %@." = "Getestet gegen Datenverkehr ab %@.";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "%lld Entscheidungen verwalteter Pakete wurden nicht in den Aktivitäten erfasst, weil die Warteschlange der Erweiterung ihre Kapazitätsgrenze erreicht hatte.";
+"Network ad and tracker blocking" = "Netzwerkweite Werbe- und Trackerblockierung";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "Offizielle Pakete verweigern appübergreifend Verbindungen zu bekannten Zielen. Sie sind deaktiviert, bis du sie einschaltest, und benötigen die Netzwerkerweiterung; sie können keine Lücken im Seitenlayout, Browser-Overlays, skriptgesteuerten Pop-ups oder nativen App-Fenster entfernen.";
+"Every app" = "Jede App";
+"%lld selected apps" = "%lld ausgewählte Apps";
+"%lld blocked" = "%lld blockiert";
+"%lld exceptions" = "%lld Ausnahmen";
+"Review…" = "Prüfen…";
+"Official · Built into Flowlight · pack v%@ · %@" = "Offiziell · In Flowlight integriert · Paket v%@ · %@";
+"Applying…" = "Wird angewendet…";
+"Watching only" = "Nur beobachten";
+"Blocking" = "Blockieren";
+"Needs extension update" = "Erweiterung muss aktualisiert werden";
+"Needs review" = "Überprüfung erforderlich";
+"Test against recent traffic" = "Mit kürzlich erfasstem Datenverkehr testen";
+"Apply this pack to every app?" = "Dieses Paket auf jede App anwenden?";
+"Apply to every app" = "Auf jede App anwenden";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "Dadurch wird die Blockierung von ausgewählten Apps auf jede App auf diesem Mac ausgeweitet. Wesentlicher Systemverkehr bleibt erlaubt.";
+"Scope" = "Umfang";
+"Only selected apps" = "Nur ausgewählte Apps";
+"App name, bundle identifier, or process" = "App-Name, Bundle-ID oder Prozess";
+"Recent-traffic impact" = "Auswirkung auf kürzlich erfassten Datenverkehr";
+"No retained traffic was available to test." = "Kein gespeicherter Datenverkehr zum Testen verfügbar.";
+"Would have changed nothing across %lld retained connections." = "Bei %lld gespeicherten Verbindungen hätte sich nichts geändert.";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "%lld von %lld Verbindungen aus %lld Apps wären verweigert worden (%@).";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%@ · %@-Paket v%@ stimmte mit %@ überein · %lld Verbindungen";
+"Allow for %@" = "Für %@ erlauben";
+"Undo" = "Rückgängig";
+"Allow…" = "Erlauben…";
+"Edit as normal rule…" = "Als normale Regel bearbeiten…";
+
/* UI/RulesView.swift */
+"Packs" = "Pakete";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Regelentscheidungen und zusammengefasste Paketblockierungen erscheinen hier. Paketaktivitäten werden gruppiert, damit eine Seite voller Werbung nicht zu einer Seite voller Warnungen wird.";
+"Managed packs" = "Verwaltete Pakete";
"What they did" = "Wirkung";
"Paused until %@" = "Pausiert bis %@";
"%lld in force" = "%lld in Kraft";
@@ -1184,7 +1219,6 @@
"Browse Templates…" = "Vorlagen durchsuchen…";
"Write One…" = "Eine schreiben…";
"Nothing yet" = "Noch nichts";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "Jede Verbindung, über die eine Regel entscheidet, landet hier: die Verweigerungen und die Ausnahmen, die etwas durchlassen. Die zweite Hälfte hält die Frage „Warum kommt das hier durch?“ beantwortbar.";
"Today" = "Heute";
"Yesterday" = "Gestern";
"A rule since deleted" = "Eine inzwischen gelöschte Regel";
@@ -1305,6 +1339,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ – die Sprache ist gesichert; Flowlight beenden und erneut öffnen schließt die Änderung ab.";
/* UI/SharedStrings.swift */
+"Advertising" = "Werbung";
+"Known advertising delivery and auction hosts across apps." = "Bekannte Hosts für Werbeauslieferung und -auktionen in verschiedenen Apps.";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "Das Blockieren von Verbindungen kann keine Lücken im Seitenlayout, Erstanbieterwerbung oder von einem Browser oder einer App dargestellte Overlays entfernen.";
+"Cross-site tracking" = "Websiteübergreifendes Tracking";
+"Common analytics, session replay, and cross-site measurement hosts." = "Gängige Hosts für Analysen, Sitzungswiedergaben und websiteübergreifende Messungen.";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "Einige Apps nutzen dieselben Anbieter für Feature-Flags. Wenn ein Host nicht verfügbar ist, greifen sie möglicherweise auf integrierte Standardwerte zurück.";
+"Privacy and telemetry" = "Datenschutz und Telemetrie";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "Hosts für Absturzberichte, Installationszuordnung sowie geprüfte Werbe- und Analysefunktionen von Apple.";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "Absturzberichte helfen Entwicklern, Fehler zu beheben. Wesentlicher Datenverkehr für Apple-Konten, Push-Mitteilungen, Updates, Zertifikate und iCloud bleibt erlaubt.";
"Any agent" = "Jeder Agent";
"%@: no %@" = "%@: kein %@";
"everything from %@" = "alles von %@";
diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings
index d04095bc..181501db 100644
--- a/Flowlight/Localization/en.lproj/Localizable.strings
+++ b/Flowlight/Localization/en.lproj/Localizable.strings
@@ -1089,7 +1089,42 @@
"Allow %1$@ requests from %2$@ to %3$@." = "Allow %1$@ requests from %2$@ to %3$@.";
"anywhere" = "anywhere";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full.";
+"Network ad and tracker blocking" = "Network ad and tracker blocking";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows.";
+"Every app" = "Every app";
+"%lld selected apps" = "%lld selected apps";
+"%lld blocked" = "%lld blocked";
+"%lld exceptions" = "%lld exceptions";
+"Review…" = "Review…";
+"Official · Built into Flowlight · pack v%@ · %@" = "Official · Built into Flowlight · pack v%@ · %@";
+"Applying…" = "Applying…";
+"Watching only" = "Watching only";
+"Blocking" = "Blocking";
+"Needs extension update" = "Needs extension update";
+"Needs review" = "Needs review";
+"Test against recent traffic" = "Test against recent traffic";
+"Apply this pack to every app?" = "Apply this pack to every app?";
+"Apply to every app" = "Apply to every app";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed.";
+"Scope" = "Scope";
+"Only selected apps" = "Only selected apps";
+"App name, bundle identifier, or process" = "App name, bundle identifier, or process";
+"Recent-traffic impact" = "Recent-traffic impact";
+"No retained traffic was available to test." = "No retained traffic was available to test.";
+"Would have changed nothing across %lld retained connections." = "Would have changed nothing across %lld retained connections.";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "Would have refused %lld of %lld connections from %lld apps (%@).";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%@ · %@ pack v%@ matched %@ · %lld connections";
+"Allow for %@" = "Allow for %@";
+"Undo" = "Undo";
+"Allow…" = "Allow…";
+"Edit as normal rule…" = "Edit as normal rule…";
+
/* UI/RulesView.swift */
+"Packs" = "Packs";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts.";
+"Managed packs" = "Managed packs";
"What they did" = "What they did";
"Paused until %@" = "Paused until %@";
"%lld in force" = "%lld in force";
@@ -1116,7 +1151,6 @@
"Browse Templates…" = "Browse Templates…";
"Write One…" = "Write One…";
"Nothing yet" = "Nothing yet";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable.";
"Today" = "Today";
"Yesterday" = "Yesterday";
"A rule since deleted" = "A rule since deleted";
@@ -1237,6 +1271,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ — the language is saved, so quitting and opening Flowlight again will finish the change.";
/* UI/SharedStrings.swift */
+"Advertising" = "Advertising";
+"Known advertising delivery and auction hosts across apps." = "Known advertising delivery and auction hosts across apps.";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app.";
+"Cross-site tracking" = "Cross-site tracking";
+"Common analytics, session replay, and cross-site measurement hosts." = "Common analytics, session replay, and cross-site measurement hosts.";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable.";
+"Privacy and telemetry" = "Privacy and telemetry";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "Crash reporting, install attribution, and audited Apple advertising and analytics hosts.";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed.";
"Any agent" = "Any agent";
"%@: no %@" = "%@: no %@";
"everything from %@" = "everything from %@";
diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings
index 3488203c..ad808b80 100644
--- a/Flowlight/Localization/es.lproj/Localizable.strings
+++ b/Flowlight/Localization/es.lproj/Localizable.strings
@@ -1157,7 +1157,42 @@
"and %lld more" = "y %lld más";
"Tested against traffic back to %@." = "Probado con el tráfico desde %@.";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "Se omitieron %lld decisiones de paquetes gestionados de la actividad porque la cola de capacidad limitada de la extensión estaba llena.";
+"Network ad and tracker blocking" = "Bloqueo de anuncios y rastreadores en la red";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "Los paquetes oficiales rechazan conexiones a destinos conocidos en todas las apps. Están desactivados hasta que los actives y necesitan la extensión de red; no pueden eliminar huecos en el diseño de las páginas, superposiciones del navegador, ventanas emergentes creadas por scripts ni ventanas nativas de las apps.";
+"Every app" = "Todas las apps";
+"%lld selected apps" = "%lld apps seleccionadas";
+"%lld blocked" = "%lld bloqueadas";
+"%lld exceptions" = "%lld excepciones";
+"Review…" = "Revisar…";
+"Official · Built into Flowlight · pack v%@ · %@" = "Oficial · Integrado en Flowlight · paquete v%@ · %@";
+"Applying…" = "Aplicando…";
+"Watching only" = "Solo vigilancia";
+"Blocking" = "Bloqueando";
+"Needs extension update" = "Necesita actualizar la extensión";
+"Needs review" = "Necesita revisión";
+"Test against recent traffic" = "Probar con el tráfico reciente";
+"Apply this pack to every app?" = "¿Aplicar este paquete a todas las apps?";
+"Apply to every app" = "Aplicar a todas las apps";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "Esto amplía el bloqueo de las apps seleccionadas a todas las apps de este Mac. El tráfico esencial del sistema seguirá permitido.";
+"Scope" = "Ámbito";
+"Only selected apps" = "Solo las apps seleccionadas";
+"App name, bundle identifier, or process" = "Nombre de la app, identificador de bundle o proceso";
+"Recent-traffic impact" = "Impacto en el tráfico reciente";
+"No retained traffic was available to test." = "No había tráfico conservado disponible para probar.";
+"Would have changed nothing across %lld retained connections." = "No habría cambiado nada en %lld conexiones conservadas.";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "Habría rechazado %lld de %lld conexiones de %lld apps (%@).";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%@ · el paquete %@ v%@ coincidió con %@ · %lld conexiones";
+"Allow for %@" = "Permitir para %@";
+"Undo" = "Deshacer";
+"Allow…" = "Permitir…";
+"Edit as normal rule…" = "Editar como regla normal…";
+
/* UI/RulesView.swift */
+"Packs" = "Paquetes";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Aquí aparecen las decisiones de las reglas y los bloqueos resumidos de los paquetes. La actividad de los paquetes se agrupa para que una página llena de anuncios no se convierta en una página llena de alertas.";
+"Managed packs" = "Paquetes gestionados";
"What they did" = "Qué han hecho";
"Paused until %@" = "En pausa hasta %@";
"%lld in force" = "%lld en vigor";
@@ -1184,7 +1219,6 @@
"Browse Templates…" = "Ver las plantillas…";
"Write One…" = "Escribir una…";
"Nothing yet" = "Todavía nada";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "Aquí llega cada conexión que decide una regla: los rechazos y las excepciones que dejan pasar algo. Esa segunda mitad es lo que permite seguir respondiendo a «¿por qué está pasando esto?».";
"Today" = "Hoy";
"Yesterday" = "Ayer";
"A rule since deleted" = "Una regla ya eliminada";
@@ -1305,6 +1339,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ — el idioma está guardado, así que al cerrar y volver a abrir Flowlight se completará el cambio.";
/* UI/SharedStrings.swift */
+"Advertising" = "Publicidad";
+"Known advertising delivery and auction hosts across apps." = "Hosts conocidos de distribución y subasta de publicidad en distintas apps.";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "El bloqueo de conexiones no puede eliminar huecos en el diseño de las páginas, anuncios propios ni superposiciones dibujadas por un navegador o una app.";
+"Cross-site tracking" = "Seguimiento entre sitios";
+"Common analytics, session replay, and cross-site measurement hosts." = "Hosts habituales de analítica, reproducción de sesiones y medición entre sitios.";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "Algunas apps usan los mismos proveedores para indicadores de funciones. Si un host no está disponible, pueden recurrir a los valores predeterminados integrados.";
+"Privacy and telemetry" = "Privacidad y telemetría";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "Hosts de informes de fallos, atribución de instalaciones y publicidad y analítica de Apple auditados.";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "Los informes de fallos ayudan a los desarrolladores a corregir errores. El tráfico esencial de cuentas de Apple, notificaciones push, actualizaciones, certificados e iCloud seguirá permitido.";
"Any agent" = "Cualquier agente";
"%@: no %@" = "%@: sin %@";
"everything from %@" = "todo lo de %@";
diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings
index c6c88cd8..767e2253 100644
--- a/Flowlight/Localization/fr.lproj/Localizable.strings
+++ b/Flowlight/Localization/fr.lproj/Localizable.strings
@@ -1157,7 +1157,42 @@
"and %lld more" = "et %lld de plus";
"Tested against traffic back to %@." = "Testée sur le trafic remontant jusqu'à %@.";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "%lld décisions de packs gérés ont été omises de l’activité, car la file d’attente à capacité limitée de l’extension était pleine.";
+"Network ad and tracker blocking" = "Blocage réseau des publicités et des traqueurs";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "Les packs officiels refusent les connexions à des destinations connues dans toutes les apps. Ils restent désactivés jusqu’à ce que vous les activiez et nécessitent l’extension réseau ; ils ne peuvent pas supprimer les espaces vides dans les pages, les incrustations du navigateur, les fenêtres surgissantes créées par des scripts ni les fenêtres natives des apps.";
+"Every app" = "Toutes les apps";
+"%lld selected apps" = "%lld apps sélectionnées";
+"%lld blocked" = "%lld bloquées";
+"%lld exceptions" = "%lld exceptions";
+"Review…" = "Examiner…";
+"Official · Built into Flowlight · pack v%@ · %@" = "Officiel · Intégré à Flowlight · pack v%@ · %@";
+"Applying…" = "Application…";
+"Watching only" = "Surveillance seulement";
+"Blocking" = "Blocage";
+"Needs extension update" = "Mise à jour de l’extension requise";
+"Needs review" = "Examen requis";
+"Test against recent traffic" = "Tester sur le trafic récent";
+"Apply this pack to every app?" = "Appliquer ce pack à toutes les apps ?";
+"Apply to every app" = "Appliquer à toutes les apps";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "Cela étend le blocage des apps sélectionnées à toutes les apps de ce Mac. Le trafic système essentiel reste autorisé.";
+"Scope" = "Portée";
+"Only selected apps" = "Uniquement les apps sélectionnées";
+"App name, bundle identifier, or process" = "Nom d’app, identifiant de bundle ou processus";
+"Recent-traffic impact" = "Impact sur le trafic récent";
+"No retained traffic was available to test." = "Aucun trafic conservé n’était disponible pour le test.";
+"Would have changed nothing across %lld retained connections." = "Cela n’aurait rien changé sur %lld connexions conservées.";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "Cela aurait refusé %lld connexions sur %lld provenant de %lld apps (%@).";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%@ · le pack %@ v%@ a reconnu %@ · %lld connexions";
+"Allow for %@" = "Autoriser pour %@";
+"Undo" = "Annuler";
+"Allow…" = "Autoriser…";
+"Edit as normal rule…" = "Modifier comme règle normale…";
+
/* UI/RulesView.swift */
+"Packs" = "Packs";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Les décisions des règles et les blocages résumés des packs apparaissent ici. L’activité des packs est regroupée afin qu’une page remplie de publicités ne devienne pas une page remplie d’alertes.";
+"Managed packs" = "Packs gérés";
"What they did" = "Ce qu'elles ont fait";
"Paused until %@" = "Suspendu jusqu'à %@";
"%lld in force" = "%lld en vigueur";
@@ -1184,7 +1219,6 @@
"Browse Templates…" = "Parcourir les modèles…";
"Write One…" = "En écrire une…";
"Nothing yet" = "Rien pour l'instant";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "Chaque connexion décidée par une règle aboutit ici : les refus, et les exceptions qui laissent passer quelque chose. La seconde moitié est ce qui permet de répondre à « pourquoi est-ce que ça passe ? ».";
"Today" = "Aujourd'hui";
"Yesterday" = "Hier";
"A rule since deleted" = "Une règle depuis supprimée";
@@ -1305,6 +1339,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ — la langue est enregistrée : quitter puis rouvrir Flowlight terminera le changement.";
/* UI/SharedStrings.swift */
+"Advertising" = "Publicité";
+"Known advertising delivery and auction hosts across apps." = "Hôtes connus de diffusion et de mise aux enchères publicitaires dans différentes apps.";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "Le blocage des connexions ne peut pas supprimer les espaces vides dans les pages, les publicités propriétaires ni les incrustations affichées par un navigateur ou une app.";
+"Cross-site tracking" = "Suivi intersites";
+"Common analytics, session replay, and cross-site measurement hosts." = "Hôtes courants d’analyse, de relecture de session et de mesure intersites.";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "Certaines apps utilisent les mêmes fournisseurs pour les indicateurs de fonctionnalité. Elles peuvent revenir aux valeurs par défaut intégrées lorsqu’un hôte est indisponible.";
+"Privacy and telemetry" = "Confidentialité et télémétrie";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "Hôtes de rapports de plantage, d’attribution d’installation et de publicité et d’analyse Apple audités.";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "Les rapports de plantage aident les développeurs à corriger les défaillances. Le trafic essentiel des comptes Apple, des notifications push, des mises à jour, des certificats et d’iCloud reste autorisé.";
"Any agent" = "Tout agent";
"%@: no %@" = "%@ : pas de %@";
"everything from %@" = "tout ce qui vient de %@";
diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings
index 8fd4ff70..44f376f9 100644
--- a/Flowlight/Localization/it.lproj/Localizable.strings
+++ b/Flowlight/Localization/it.lproj/Localizable.strings
@@ -1157,7 +1157,42 @@
"and %lld more" = "e altre %lld";
"Tested against traffic back to %@." = "Testata sul traffico a partire da %@.";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "%lld decisioni dei pacchetti gestiti sono state omesse dall’attività perché la coda a capacità limitata dell’estensione era piena.";
+"Network ad and tracker blocking" = "Blocco in rete di pubblicità e tracker";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "I pacchetti ufficiali rifiutano le connessioni a destinazioni note in tutte le app. Restano disattivati finché non li abiliti e richiedono l’estensione di rete; non possono rimuovere spazi vuoti nel layout delle pagine, sovrapposizioni del browser, popup generati da script o finestre native delle app.";
+"Every app" = "Tutte le app";
+"%lld selected apps" = "%lld app selezionate";
+"%lld blocked" = "%lld bloccate";
+"%lld exceptions" = "%lld eccezioni";
+"Review…" = "Esamina…";
+"Official · Built into Flowlight · pack v%@ · %@" = "Ufficiale · Integrato in Flowlight · pacchetto v%@ · %@";
+"Applying…" = "Applicazione…";
+"Watching only" = "Solo osservazione";
+"Blocking" = "Blocco";
+"Needs extension update" = "Richiede l’aggiornamento dell’estensione";
+"Needs review" = "Richiede una verifica";
+"Test against recent traffic" = "Prova sul traffico recente";
+"Apply this pack to every app?" = "Applicare questo pacchetto a tutte le app?";
+"Apply to every app" = "Applica a tutte le app";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "Questo estende il blocco dalle app selezionate a tutte le app su questo Mac. Il traffico essenziale di sistema resta consentito.";
+"Scope" = "Ambito";
+"Only selected apps" = "Solo le app selezionate";
+"App name, bundle identifier, or process" = "Nome app, identificatore bundle o processo";
+"Recent-traffic impact" = "Impatto sul traffico recente";
+"No retained traffic was available to test." = "Non era disponibile traffico conservato su cui eseguire la prova.";
+"Would have changed nothing across %lld retained connections." = "Non avrebbe cambiato nulla su %lld connessioni conservate.";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "Avrebbe rifiutato %lld connessioni su %lld da %lld app (%@).";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%@ · il pacchetto %@ v%@ ha trovato %@ · %lld connessioni";
+"Allow for %@" = "Consenti per %@";
+"Undo" = "Annulla";
+"Allow…" = "Consenti…";
+"Edit as normal rule…" = "Modifica come regola normale…";
+
/* UI/RulesView.swift */
+"Packs" = "Pacchetti";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Qui compaiono le decisioni delle regole e i blocchi riepilogati dei pacchetti. L’attività dei pacchetti viene raggruppata, così una pagina piena di pubblicità non diventa una pagina piena di avvisi.";
+"Managed packs" = "Pacchetti gestiti";
"What they did" = "Cosa hanno fatto";
"Paused until %@" = "In pausa fino a %@";
"%lld in force" = "%lld in vigore";
@@ -1184,7 +1219,6 @@
"Browse Templates…" = "Sfoglia i modelli…";
"Write One…" = "Scrivine una…";
"Nothing yet" = "Ancora nulla";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "Ogni connessione decisa da una regola finisce qui: i rifiuti e le eccezioni che lasciano passare qualcosa. La seconda metà è ciò che tiene in piedi la risposta alla domanda «perché questo sta passando?».";
"Today" = "Oggi";
"Yesterday" = "Ieri";
"A rule since deleted" = "Una regola poi eliminata";
@@ -1305,6 +1339,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ — la lingua è salvata: chiudendo e riaprendo Flowlight il cambio sarà completato.";
/* UI/SharedStrings.swift */
+"Advertising" = "Pubblicità";
+"Known advertising delivery and auction hosts across apps." = "Host noti per la distribuzione e le aste pubblicitarie nelle varie app.";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "Il blocco delle connessioni non può rimuovere spazi vuoti nel layout delle pagine, pubblicità proprietarie o sovrapposizioni mostrate da un browser o un’app.";
+"Cross-site tracking" = "Tracciamento tra siti";
+"Common analytics, session replay, and cross-site measurement hosts." = "Host comuni per analisi, riproduzione delle sessioni e misurazione tra siti.";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "Alcune app usano gli stessi fornitori per i flag delle funzionalità. Se un host non è disponibile, possono ripiegare sui valori predefiniti integrati.";
+"Privacy and telemetry" = "Privacy e telemetria";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "Host verificati per segnalazioni di arresti anomali, attribuzione delle installazioni, pubblicità e analisi Apple.";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "Le segnalazioni di arresti anomali aiutano gli sviluppatori a correggere i problemi. Il traffico essenziale per account Apple, notifiche push, aggiornamenti, certificati e iCloud resta consentito.";
"Any agent" = "Qualsiasi agente";
"%@: no %@" = "%@: niente %@";
"everything from %@" = "tutto da %@";
diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings
index 39693ba5..c5610e2d 100644
--- a/Flowlight/Localization/ja.lproj/Localizable.strings
+++ b/Flowlight/Localization/ja.lproj/Localizable.strings
@@ -1157,7 +1157,42 @@
"and %lld more" = "ほか %lld 件";
"Tested against traffic back to %@." = "%@ 以降のトラフィックでテストしました。";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "拡張機能の容量制限付きキューが満杯だったため、管理対象パックの判定 %lld 件がアクティビティから除外されました。";
+"Network ad and tracker blocking" = "ネットワークで広告とトラッカーをブロック";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "公式パックは、アプリをまたいで既知の接続先への接続を拒否します。有効にするまではオフで、ネットワーク拡張が必要です。ページレイアウトの空白、ブラウザのオーバーレイ、スクリプトによるポップアップ、アプリ固有のウインドウは削除できません。";
+"Every app" = "すべてのアプリ";
+"%lld selected apps" = "選択したアプリ %lld 個";
+"%lld blocked" = "%lld 件をブロック";
+"%lld exceptions" = "例外 %lld 件";
+"Review…" = "確認…";
+"Official · Built into Flowlight · pack v%@ · %@" = "公式 · Flowlight 内蔵 · パック v%@ · %@";
+"Applying…" = "適用中…";
+"Watching only" = "監視のみ";
+"Blocking" = "ブロック中";
+"Needs extension update" = "拡張機能の更新が必要";
+"Needs review" = "確認が必要";
+"Test against recent traffic" = "最近のトラフィックでテスト";
+"Apply this pack to every app?" = "このパックをすべてのアプリに適用しますか?";
+"Apply to every app" = "すべてのアプリに適用";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "ブロック対象を選択したアプリから、この Mac 上のすべてのアプリへ広げます。システムに不可欠な通信は引き続き許可されます。";
+"Scope" = "範囲";
+"Only selected apps" = "選択したアプリのみ";
+"App name, bundle identifier, or process" = "アプリ名、バンドル識別子、またはプロセス";
+"Recent-traffic impact" = "最近のトラフィックへの影響";
+"No retained traffic was available to test." = "テストに使える保持済みトラフィックがありませんでした。";
+"Would have changed nothing across %lld retained connections." = "保持済みの %lld 接続には何も変更を加えなかったはずです。";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "%3$lld 個のアプリによる %2$lld 接続のうち %1$lld 件を拒否したはずです(%4$@)。";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%1$@ · %2$@パック v%3$@ が %4$@ に一致 · %5$lld 接続";
+"Allow for %@" = "%@ では許可";
+"Undo" = "取り消す";
+"Allow…" = "許可…";
+"Edit as normal rule…" = "通常のルールとして編集…";
+
/* UI/RulesView.swift */
+"Packs" = "パック";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "ルールの判断とパックによるブロックの要約がここに表示されます。広告だらけのページがアラートだらけにならないよう、パックのアクティビティはまとめられます。";
+"Managed packs" = "管理対象パック";
"What they did" = "判断の記録";
"Paused until %@" = "%@ まで一時停止中";
"%lld in force" = "%lld 件が有効";
@@ -1184,7 +1219,6 @@
"Browse Templates…" = "テンプレートを見る…";
"Write One…" = "作成する…";
"Nothing yet" = "まだ何もありません";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "ルールが判断した接続はすべてここに記録されます。拒否したものと、通過を許した例外の両方です。後者があることで、「なぜこれが通っているのか」に答えられる状態が保たれます。";
"Today" = "今日";
"Yesterday" = "昨日";
"A rule since deleted" = "すでに削除されたルール";
@@ -1305,6 +1339,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ — 言語は保存されているため、Flowlight を終了して開き直せば変更が完了します。";
/* UI/SharedStrings.swift */
+"Advertising" = "広告";
+"Known advertising delivery and auction hosts across apps." = "アプリをまたいで使われる既知の広告配信・広告オークションホスト。";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "接続をブロックしても、ページレイアウトの空白、ファーストパーティ広告、ブラウザやアプリが表示するオーバーレイは削除できません。";
+"Cross-site tracking" = "サイト横断トラッキング";
+"Common analytics, session replay, and cross-site measurement hosts." = "一般的なアクセス解析、セッションリプレイ、サイト横断測定のホスト。";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "一部のアプリは同じプロバイダを機能フラグにも使っています。ホストが利用できない場合、内蔵の既定値に切り替わることがあります。";
+"Privacy and telemetry" = "プライバシーとテレメトリ";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "クラッシュ報告、インストール計測、監査済みの Apple 広告・解析ホスト。";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "クラッシュ報告は開発者による不具合修正に役立ちます。Apple アカウント、プッシュ通知、アップデート、証明書、iCloud に不可欠な通信は引き続き許可されます。";
"Any agent" = "すべてのエージェント";
"%@: no %@" = "%@: %@ なし";
"everything from %@" = "%@ のすべて";
diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings
index 4288ccb5..7acffcbb 100644
--- a/Flowlight/Localization/ko.lproj/Localizable.strings
+++ b/Flowlight/Localization/ko.lproj/Localizable.strings
@@ -1180,7 +1180,42 @@
"and %lld more" = "그 외 %lld건";
"Tested against traffic back to %@." = "%@까지의 트래픽과 대조했습니다.";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "확장 프로그램의 용량 제한 큐가 가득 차서 관리형 팩 판단 %lld건이 활동에서 누락되었습니다.";
+"Network ad and tracker blocking" = "네트워크 광고 및 추적기 차단";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "공식 팩은 모든 앱에서 알려진 목적지로 가는 연결을 거부합니다. 직접 켜기 전에는 꺼져 있으며 네트워크 확장 프로그램이 필요합니다. 페이지 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트 팝업, 앱 자체 창은 제거할 수 없습니다.";
+"Every app" = "모든 앱";
+"%lld selected apps" = "선택한 앱 %lld개";
+"%lld blocked" = "%lld개 차단";
+"%lld exceptions" = "예외 %lld개";
+"Review…" = "검토…";
+"Official · Built into Flowlight · pack v%@ · %@" = "공식 · Flowlight 내장 · 팩 v%@ · %@";
+"Applying…" = "적용 중…";
+"Watching only" = "관찰만";
+"Blocking" = "차단 중";
+"Needs extension update" = "확장 프로그램 업데이트 필요";
+"Needs review" = "검토 필요";
+"Test against recent traffic" = "최근 트래픽으로 테스트";
+"Apply this pack to every app?" = "이 팩을 모든 앱에 적용할까요?";
+"Apply to every app" = "모든 앱에 적용";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "차단 범위를 선택한 앱에서 이 Mac의 모든 앱으로 넓힙니다. 필수 시스템 트래픽은 계속 허용됩니다.";
+"Scope" = "범위";
+"Only selected apps" = "선택한 앱만";
+"App name, bundle identifier, or process" = "앱 이름, 번들 식별자 또는 프로세스";
+"Recent-traffic impact" = "최근 트래픽에 미치는 영향";
+"No retained traffic was available to test." = "테스트할 보관 트래픽이 없습니다.";
+"Would have changed nothing across %lld retained connections." = "보관된 연결 %lld건에는 아무 변화도 주지 않았을 것입니다.";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "앱 %3$lld개에서 나가는 연결 %2$lld건 가운데 %1$lld건을 거부했을 것입니다(%4$@).";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%1$@ · %2$@ 팩 v%3$@이(가) %4$@와 일치 · 연결 %5$lld건";
+"Allow for %@" = "%@에서는 허용";
+"Undo" = "실행 취소";
+"Allow…" = "허용…";
+"Edit as normal rule…" = "일반 규칙으로 편집…";
+
/* UI/RulesView.swift */
+"Packs" = "팩";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "규칙 판단과 팩 차단 요약이 여기에 표시됩니다. 광고가 많은 페이지 때문에 알림이 가득 차지 않도록 팩 활동은 묶어서 표시됩니다.";
+"Managed packs" = "관리형 팩";
"What they did" = "동작 내역";
"Paused until %@" = "%@까지 일시 중지됨";
"%lld in force" = "%lld개 적용 중";
@@ -1207,7 +1242,6 @@
"Browse Templates…" = "템플릿 둘러보기…";
"Write One…" = "직접 작성…";
"Nothing yet" = "아직 없음";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "규칙이 판단한 모든 연결이 여기에 남습니다. 거부한 연결뿐 아니라 통과시킨 예외도 함께 기록됩니다. 뒤쪽 절반이 있어야 “이건 왜 통과됐지?”라는 질문에 답할 수 있습니다.";
"Today" = "오늘";
"Yesterday" = "어제";
"A rule since deleted" = "이후 삭제된 규칙";
@@ -1328,6 +1362,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ — 언어는 저장되었으므로, Flowlight를 종료한 뒤 다시 열면 변경이 완료됩니다.";
/* UI/SharedStrings.swift */
+"Advertising" = "광고";
+"Known advertising delivery and auction hosts across apps." = "여러 앱에서 사용되는 알려진 광고 전송 및 경매 호스트입니다.";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "연결 차단으로는 페이지 레이아웃의 빈 공간, 자사 광고, 브라우저나 앱이 표시하는 오버레이를 제거할 수 없습니다.";
+"Cross-site tracking" = "사이트 간 추적";
+"Common analytics, session replay, and cross-site measurement hosts." = "일반적인 분석, 세션 재생 및 사이트 간 측정 호스트입니다.";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "일부 앱은 기능 플래그에도 같은 제공업체를 사용합니다. 호스트를 사용할 수 없으면 내장 기본값으로 대체될 수 있습니다.";
+"Privacy and telemetry" = "개인정보 보호 및 원격 측정";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "충돌 보고, 설치 기여도 측정 및 감사된 Apple 광고·분석 호스트입니다.";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "충돌 보고서는 개발자가 오류를 수정하는 데 도움이 됩니다. Apple 계정, 푸시, 업데이트, 인증서 및 iCloud에 필수적인 트래픽은 계속 허용됩니다.";
"Any agent" = "모든 에이전트";
"%@: no %@" = "%@: %@ 차단";
"everything from %@" = "%@의 모든 도구";
diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings
index 8e91b70e..a21b4f9e 100644
--- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings
+++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings
@@ -1180,7 +1180,42 @@
"and %lld more" = "e mais %lld";
"Tested against traffic back to %@." = "Testado contra o tráfego desde %@.";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "%lld decisões de pacotes geridos foram omitidas da atividade porque a fila de capacidade limitada da extensão estava cheia.";
+"Network ad and tracker blocking" = "Bloqueio de anúncios e rastreadores na rede";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "Os pacotes oficiais recusam ligações a destinos conhecidos em todas as apps. Ficam desativados até os ativar e precisam da extensão de rede; não conseguem remover espaços vazios no esquema das páginas, sobreposições do navegador, janelas emergentes criadas por scripts nem janelas nativas das apps.";
+"Every app" = "Todas as apps";
+"%lld selected apps" = "%lld apps selecionadas";
+"%lld blocked" = "%lld bloqueadas";
+"%lld exceptions" = "%lld exceções";
+"Review…" = "Rever…";
+"Official · Built into Flowlight · pack v%@ · %@" = "Oficial · Integrado no Flowlight · pacote v%@ · %@";
+"Applying…" = "A aplicar…";
+"Watching only" = "Só a vigiar";
+"Blocking" = "A bloquear";
+"Needs extension update" = "Precisa de atualizar a extensão";
+"Needs review" = "Precisa de revisão";
+"Test against recent traffic" = "Testar com o tráfego recente";
+"Apply this pack to every app?" = "Aplicar este pacote a todas as apps?";
+"Apply to every app" = "Aplicar a todas as apps";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "Isto alarga o bloqueio das apps selecionadas a todas as apps neste Mac. O tráfego essencial do sistema continua permitido.";
+"Scope" = "Âmbito";
+"Only selected apps" = "Apenas as apps selecionadas";
+"App name, bundle identifier, or process" = "Nome da app, identificador de pacote ou processo";
+"Recent-traffic impact" = "Impacto no tráfego recente";
+"No retained traffic was available to test." = "Não havia tráfego guardado disponível para testar.";
+"Would have changed nothing across %lld retained connections." = "Não teria mudado nada em %lld ligações guardadas.";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "Teria recusado %lld de %lld ligações de %lld apps (%@).";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%@ · o pacote %@ v%@ correspondeu a %@ · %lld ligações";
+"Allow for %@" = "Permitir para %@";
+"Undo" = "Desfazer";
+"Allow…" = "Permitir…";
+"Edit as normal rule…" = "Editar como regra normal…";
+
/* UI/RulesView.swift */
+"Packs" = "Pacotes";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "As decisões das regras e os bloqueios resumidos dos pacotes aparecem aqui. A atividade dos pacotes é agrupada para que uma página cheia de anúncios não se torne numa página cheia de alertas.";
+"Managed packs" = "Pacotes geridos";
"What they did" = "O que fizeram";
"Paused until %@" = "Em pausa até %@";
"%lld in force" = "%lld em vigor";
@@ -1207,7 +1242,6 @@
"Browse Templates…" = "Ver os modelos…";
"Write One…" = "Escrever uma…";
"Nothing yet" = "Ainda nada";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "Todas as ligações que uma regra decide aparecem aqui — as recusas e as exceções que deixam passar algo. A segunda metade é o que mantém \"porque é que isto está a passar?\" com resposta.";
"Today" = "Hoje";
"Yesterday" = "Ontem";
"A rule since deleted" = "Uma regra entretanto eliminada";
@@ -1328,6 +1362,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ — o idioma está guardado, por isso sair e abrir o Flowlight de novo conclui a alteração.";
/* UI/SharedStrings.swift */
+"Advertising" = "Publicidade";
+"Known advertising delivery and auction hosts across apps." = "Anfitriões conhecidos de distribuição e leilão de publicidade em várias apps.";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "O bloqueio de ligações não consegue remover espaços vazios no esquema das páginas, anúncios próprios ou sobreposições apresentadas por um navegador ou uma app.";
+"Cross-site tracking" = "Rastreio entre sites";
+"Common analytics, session replay, and cross-site measurement hosts." = "Anfitriões comuns de análise, reprodução de sessões e medição entre sites.";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "Algumas apps usam os mesmos fornecedores para sinalizadores de funcionalidades. Podem recorrer aos valores predefinidos integrados quando um anfitrião não está disponível.";
+"Privacy and telemetry" = "Privacidade e telemetria";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "Anfitriões auditados de relatórios de falhas, atribuição de instalações e publicidade e análise da Apple.";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "Os relatórios de falhas ajudam os programadores a corrigir problemas. O tráfego essencial de contas Apple, notificações push, atualizações, certificados e iCloud continua permitido.";
"Any agent" = "Qualquer agente";
"%@: no %@" = "%@: sem %@";
"everything from %@" = "tudo de %@";
diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
index f8c91553..658170f8 100644
--- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
+++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
@@ -1180,7 +1180,42 @@
"and %lld more" = "还有 %lld 项";
"Tested against traffic back to %@." = "对照了追溯到 %@ 的流量。";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "由于扩展的限容量队列已满,%lld 个托管包判定未记录到活动中。";
+"Network ad and tracker blocking" = "网络广告与跟踪器阻止";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "官方包会在各个应用中拒绝连接已知目标。启用前它们处于关闭状态,并且需要网络扩展;它们无法移除页面布局空白、浏览器浮层、脚本弹窗或应用原生窗口。";
+"Every app" = "所有应用";
+"%lld selected apps" = "已选择 %lld 个应用";
+"%lld blocked" = "已阻止 %lld 个";
+"%lld exceptions" = "%lld 个例外";
+"Review…" = "检查…";
+"Official · Built into Flowlight · pack v%@ · %@" = "官方 · Flowlight 内置 · 包 v%@ · %@";
+"Applying…" = "正在应用…";
+"Watching only" = "仅监视";
+"Blocking" = "正在阻止";
+"Needs extension update" = "需要更新扩展";
+"Needs review" = "需要检查";
+"Test against recent traffic" = "用最近的流量测试";
+"Apply this pack to every app?" = "将此包应用于所有应用?";
+"Apply to every app" = "应用于所有应用";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "这会把阻止范围从所选应用扩大到这台 Mac 上的所有应用。必要的系统流量仍会被允许。";
+"Scope" = "范围";
+"Only selected apps" = "仅所选应用";
+"App name, bundle identifier, or process" = "应用名称、Bundle ID 或进程";
+"Recent-traffic impact" = "对最近流量的影响";
+"No retained traffic was available to test." = "没有可供测试的已保留流量。";
+"Would have changed nothing across %lld retained connections." = "在已保留的 %lld 个连接中不会改变任何内容。";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "本来会拒绝来自 %3$lld 个应用的 %2$lld 个连接中的 %1$lld 个(%4$@)。";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%1$@ · %2$@ 包 v%3$@ 匹配了 %4$@ · %5$lld 个连接";
+"Allow for %@" = "为 %@ 允许";
+"Undo" = "撤销";
+"Allow…" = "允许…";
+"Edit as normal rule…" = "作为普通规则编辑…";
+
/* UI/RulesView.swift */
+"Packs" = "包";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "规则判定和包的阻止摘要会显示在这里。包活动会被分组,以免满是广告的页面变成满是警报的页面。";
+"Managed packs" = "托管包";
"What they did" = "它们做了什么";
"Paused until %@" = "已暂停至 %@";
"%lld in force" = "%lld 条生效中";
@@ -1207,7 +1242,6 @@
"Browse Templates…" = "浏览模板…";
"Write One…" = "写一条…";
"Nothing yet" = "暂无内容";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "每一条由规则判定的连接都会出现在这里 —— 既包括拒绝,也包括放行的例外。正是有了后半部分,“这东西为什么能通过?”才始终有答案。";
"Today" = "今天";
"Yesterday" = "昨天";
"A rule since deleted" = "一条已被删除的规则";
@@ -1328,6 +1362,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ —— 语言已保存,因此退出并重新打开 Flowlight 即可完成更改。";
/* UI/SharedStrings.swift */
+"Advertising" = "广告";
+"Known advertising delivery and auction hosts across apps." = "各个应用使用的已知广告投放与竞价主机。";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "阻止连接无法移除页面布局空白、第一方广告,或浏览器和应用绘制的浮层。";
+"Cross-site tracking" = "跨站跟踪";
+"Common analytics, session replay, and cross-site measurement hosts." = "常见的分析、会话回放和跨站衡量主机。";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "一些应用也使用相同的提供方来管理功能开关。主机不可用时,它们可能会回退到内置默认值。";
+"Privacy and telemetry" = "隐私与遥测";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "崩溃报告、安装归因,以及经审核的 Apple 广告与分析主机。";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "崩溃报告有助于开发者修复故障。Apple 账户、推送、更新、证书和 iCloud 所需的关键流量仍会被允许。";
"Any agent" = "任意代理";
"%@: no %@" = "%@:禁用 %@";
"everything from %@" = "%@ 的全部工具";
diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
index def5fe42..15820da2 100644
--- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
+++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
@@ -1180,7 +1180,42 @@
"and %lld more" = "還有 %lld 項";
"Tested against traffic back to %@." = "對照了回溯到 %@ 的流量。";
+/* UI/RulePacksView.swift */
+"%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full." = "由於延伸功能的限容量佇列已滿,%lld 個受管理套件決定未記錄到活動中。";
+"Network ad and tracker blocking" = "網路廣告與追蹤器封鎖";
+"Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows." = "官方套件會在各個 App 中拒絕連線已知目標。啟用前它們處於關閉狀態,並且需要網路延伸功能;它們無法移除頁面配置空白、瀏覽器浮層、指令碼彈出視窗或 App 原生視窗。";
+"Every app" = "所有 App";
+"%lld selected apps" = "已選取 %lld 個 App";
+"%lld blocked" = "已封鎖 %lld 個";
+"%lld exceptions" = "%lld 個例外";
+"Review…" = "檢視…";
+"Official · Built into Flowlight · pack v%@ · %@" = "官方 · Flowlight 內建 · 套件 v%@ · %@";
+"Applying…" = "正在套用…";
+"Watching only" = "僅監看";
+"Blocking" = "正在封鎖";
+"Needs extension update" = "需要更新延伸功能";
+"Needs review" = "需要檢視";
+"Test against recent traffic" = "使用最近的流量測試";
+"Apply this pack to every app?" = "將此套件套用到所有 App?";
+"Apply to every app" = "套用到所有 App";
+"This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed." = "這會把封鎖範圍從所選 App 擴大到這台 Mac 上的所有 App。必要的系統流量仍會被允許。";
+"Scope" = "範圍";
+"Only selected apps" = "僅所選 App";
+"App name, bundle identifier, or process" = "App 名稱、Bundle ID 或程序";
+"Recent-traffic impact" = "對最近流量的影響";
+"No retained traffic was available to test." = "沒有可供測試的已保留流量。";
+"Would have changed nothing across %lld retained connections." = "在已保留的 %lld 個連線中不會改變任何內容。";
+"Would have refused %lld of %lld connections from %lld apps (%@)." = "本來會拒絕來自 %3$lld 個 App 的 %2$lld 個連線中的 %1$lld 個(%4$@)。";
+"%@ · %@ pack v%@ matched %@ · %lld connections" = "%1$@ · %2$@ 套件 v%3$@ 符合 %4$@ · %5$lld 個連線";
+"Allow for %@" = "為 %@ 允許";
+"Undo" = "還原";
+"Allow…" = "允許…";
+"Edit as normal rule…" = "編輯為一般規則…";
+
/* UI/RulesView.swift */
+"Packs" = "套件";
+"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "規則決定和套件的封鎖摘要會顯示在這裡。套件活動會被分組,以免滿是廣告的頁面變成滿是警示的頁面。";
+"Managed packs" = "受管理套件";
"What they did" = "它們做了什麼";
"Paused until %@" = "已暫停至 %@";
"%lld in force" = "%lld 條生效中";
@@ -1207,7 +1242,6 @@
"Browse Templates…" = "瀏覽範本…";
"Write One…" = "自己寫一條…";
"Nothing yet" = "還沒有任何項目";
-"Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable." = "每一條由規則決定的連線都會出現在這裡 — 被拒絕的,以及例外放行的。後者的存在,正是為了讓「為什麼這個還通得過?」始終有答案。";
"Today" = "今天";
"Yesterday" = "昨天";
"A rule since deleted" = "一條之後被刪除的規則";
@@ -1328,6 +1362,15 @@
"%@ — the language is saved, so quitting and opening Flowlight again will finish the change." = "%@ — 語言設定已儲存,所以結束 Flowlight 再開啟一次就會完成變更。";
/* UI/SharedStrings.swift */
+"Advertising" = "廣告";
+"Known advertising delivery and auction hosts across apps." = "各個 App 使用的已知廣告投放與競價主機。";
+"Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app." = "封鎖連線無法移除頁面配置空白、第一方廣告,或瀏覽器和 App 顯示的浮層。";
+"Cross-site tracking" = "跨網站追蹤";
+"Common analytics, session replay, and cross-site measurement hosts." = "常見的分析、工作階段重播和跨網站衡量主機。";
+"Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable." = "部分 App 也使用相同供應商來管理功能旗標。主機無法使用時,它們可能會退回內建預設值。";
+"Privacy and telemetry" = "隱私權與遙測";
+"Crash reporting, install attribution, and audited Apple advertising and analytics hosts." = "當機報告、安裝歸因,以及經過審核的 Apple 廣告與分析主機。";
+"Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed." = "當機報告有助於開發者修正故障。Apple 帳號、推播、更新、憑證和 iCloud 所需的重要流量仍會被允許。";
"Any agent" = "任何代理";
"%@: no %@" = "%@:禁用 %@";
"everything from %@" = "%@ 的所有工具";
diff --git a/Flowlight/Storage/RulePackDeliveryStats.swift b/Flowlight/Storage/RulePackDeliveryStats.swift
new file mode 100644
index 00000000..c1eba394
--- /dev/null
+++ b/Flowlight/Storage/RulePackDeliveryStats.swift
@@ -0,0 +1,6 @@
+import Foundation
+
+struct RulePackDeliveryStats: Sendable, Equatable {
+ var droppedHits: Int64
+ var droppedKeys: Int64
+}
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index c71552a8..b24939c4 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -198,6 +198,29 @@ struct RuleEventRecord: Identifiable, Sendable, Hashable {
var destination: String { host.isEmpty ? ip : host }
}
+/// Aggregated managed-pack activity. One row may represent many matching connections in a minute; pack activity
+/// answers what was blocked without turning every ad request into an alert or an unbounded event row.
+struct RulePackEventRecord: Identifiable, Sendable, Hashable {
+ var id: Int64
+ var bucket: Date
+ var packID: String
+ var packVersion: String
+ var entryID: UInt32
+ var category: RulePackEntry.Category
+ var match: RulePackEntry.Match
+ var action: RulePackEvent.Action
+ var count: Int
+ var firstAt: Date
+ var lastAt: Date
+ var bundleID: String
+ var appName: String
+ var agentKey: String
+ var agentName: String
+ var host: String
+ var matchedHost: String
+ var port: UInt16
+}
+
/// SQLite store (App Group container) with tiered rollups:
/// flows_1s → agg_1m (complete minutes) → agg_1h / agg_1d (folded from immutable minute rows).
final class TrafficDatabase: @unchecked Sendable {
@@ -455,7 +478,58 @@ final class TrafficDatabase: @unchecked Sendable {
ip TEXT NOT NULL, port INTEGER NOT NULL, path TEXT NOT NULL, method TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS rule_events_ts ON rule_events(ts);
CREATE INDEX IF NOT EXISTS rule_events_rule ON rule_events(rule_id, ts);
+ -- Managed packs keep only user state here. The verified entry lists remain in the signed app bundle.
+ CREATE TABLE IF NOT EXISTS rule_packs (
+ pack_id TEXT PRIMARY KEY, configuration TEXT NOT NULL, updated INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS rule_pack_overrides (
+ id TEXT PRIMARY KEY, override TEXT NOT NULL, updated INTEGER NOT NULL);
+ CREATE TABLE IF NOT EXISTS rule_pack_event_rollups (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, bucket INTEGER NOT NULL, pack_id TEXT NOT NULL,
+ pack_version TEXT NOT NULL, entry_id INTEGER NOT NULL, category TEXT NOT NULL,
+ match TEXT NOT NULL DEFAULT 'suffix', action TEXT NOT NULL,
+ count INTEGER NOT NULL, first_at INTEGER NOT NULL, last_at INTEGER NOT NULL,
+ bundle_id TEXT NOT NULL, app_name TEXT NOT NULL, agent TEXT NOT NULL, agent_name TEXT NOT NULL,
+ host TEXT NOT NULL, matched_host TEXT NOT NULL, port INTEGER NOT NULL,
+ UNIQUE(bucket, pack_id, pack_version, entry_id, category, match, action, bundle_id, app_name,
+ agent, agent_name, host, matched_host, port));
+ CREATE TABLE IF NOT EXISTS rule_pack_event_watermarks (
+ delivery_session TEXT PRIMARY KEY, sequence INTEGER NOT NULL, received INTEGER NOT NULL,
+ dropped_hits INTEGER NOT NULL, dropped_keys INTEGER NOT NULL);
+ CREATE INDEX IF NOT EXISTS rule_pack_events_time ON rule_pack_event_rollups(bucket DESC);
+ CREATE INDEX IF NOT EXISTS rule_pack_events_pack ON rule_pack_event_rollups(pack_id, bucket DESC);
""")
+ let packEventKeyVersion = try conn.query(
+ "SELECT watermark FROM rollup_state WHERE tier = 'schema.rulePackEventKey'",
+ map: { $0.int(0) }
+ ).first ?? 0
+ if packEventKeyVersion < 2 {
+ let oldPackEventColumns = Set(try conn.query("PRAGMA table_info(rule_pack_event_rollups)") { $0.text(1) })
+ let oldMatch = oldPackEventColumns.contains("match") ? "match" : "'suffix'"
+ try conn.transaction {
+ try conn.execute("""
+ CREATE TABLE rule_pack_event_rollups_v2 (
+ id INTEGER PRIMARY KEY AUTOINCREMENT, bucket INTEGER NOT NULL, pack_id TEXT NOT NULL,
+ pack_version TEXT NOT NULL, entry_id INTEGER NOT NULL, category TEXT NOT NULL,
+ match TEXT NOT NULL DEFAULT 'suffix', action TEXT NOT NULL,
+ count INTEGER NOT NULL, first_at INTEGER NOT NULL, last_at INTEGER NOT NULL,
+ bundle_id TEXT NOT NULL, app_name TEXT NOT NULL, agent TEXT NOT NULL, agent_name TEXT NOT NULL,
+ host TEXT NOT NULL, matched_host TEXT NOT NULL, port INTEGER NOT NULL,
+ UNIQUE(bucket, pack_id, pack_version, entry_id, category, match, action, bundle_id, app_name,
+ agent, agent_name, host, matched_host, port));
+ INSERT INTO rule_pack_event_rollups_v2
+ (id, bucket, pack_id, pack_version, entry_id, category, match, action, count, first_at, last_at,
+ bundle_id, app_name, agent, agent_name, host, matched_host, port)
+ SELECT id, bucket, pack_id, pack_version, entry_id, category, \(oldMatch), action, count, first_at, last_at,
+ bundle_id, app_name, agent, agent_name, host, matched_host, port
+ FROM rule_pack_event_rollups;
+ DROP TABLE rule_pack_event_rollups;
+ ALTER TABLE rule_pack_event_rollups_v2 RENAME TO rule_pack_event_rollups;
+ CREATE INDEX rule_pack_events_time ON rule_pack_event_rollups(bucket DESC);
+ CREATE INDEX rule_pack_events_pack ON rule_pack_event_rollups(pack_id, bucket DESC);
+ INSERT OR REPLACE INTO rollup_state (tier, watermark) VALUES ('schema.rulePackEventKey', 2);
+ """)
+ }
+ }
// Runs once. `rollup_state` already exists to remember how far a job has got, so the repair uses it
// rather than introducing a second way to record the same kind of fact.
@@ -958,6 +1032,140 @@ final class TrafficDatabase: @unchecked Sendable {
try conn.run("DELETE FROM rule_events WHERE ts < ?", [.int(Int64(date.timeIntervalSince1970))])
}
+ // MARK: Managed rule packs
+
+ func loadRulePackConfigurations() throws -> [RulePackConfiguration] {
+ try conn.query("SELECT configuration FROM rule_packs ORDER BY pack_id") { $0.text(0) }.compactMap {
+ try? JSONDecoder().decode(RulePackConfiguration.self, from: Data($0.utf8))
+ }
+ }
+
+ func saveRulePackConfiguration(_ configuration: RulePackConfiguration) throws {
+ let json = String(decoding: try JSONEncoder().encode(configuration), as: UTF8.self)
+ try conn.run("""
+ INSERT INTO rule_packs (pack_id, configuration, updated) VALUES (?,?,?)
+ ON CONFLICT(pack_id) DO UPDATE SET configuration = excluded.configuration, updated = excluded.updated
+ """, [.text(configuration.packID), .text(json), .int(Int64(Date().timeIntervalSince1970))])
+ }
+
+ func loadRulePackOverrides() throws -> [RulePackOverride] {
+ try conn.query("SELECT override FROM rule_pack_overrides ORDER BY updated") { $0.text(0) }.compactMap {
+ try? JSONDecoder().decode(RulePackOverride.self, from: Data($0.utf8))
+ }
+ }
+
+ func saveRulePackOverride(_ override: RulePackOverride) throws {
+ let json = String(decoding: try JSONEncoder().encode(override), as: UTF8.self)
+ try conn.run("""
+ INSERT INTO rule_pack_overrides (id, override, updated) VALUES (?,?,?)
+ ON CONFLICT(id) DO UPDATE SET override = excluded.override, updated = excluded.updated
+ """, [.text(override.id.uuidString), .text(json), .int(Int64(Date().timeIntervalSince1970))])
+ }
+
+ func deleteRulePackOverride(_ id: UUID) throws {
+ try conn.run("DELETE FROM rule_pack_overrides WHERE id = ?", [.text(id.uuidString)])
+ }
+
+ /// Commits a retryable extension delivery exactly once. One monotonic watermark per extension process and the
+ /// additive rollups share one transaction, so acknowledging a batch means both are durable and retries are no-ops.
+ @discardableResult
+ func recordRulePackEventBatch(_ batch: RulePackEventBatch) throws -> Bool {
+ var inserted = false
+ try conn.transaction {
+ let session = batch.deliverySession.uuidString
+ let watermark = try conn.query(
+ "SELECT sequence FROM rule_pack_event_watermarks WHERE delivery_session = ?",
+ [.text(session)], map: { $0.int(0) }
+ ).first ?? 0
+ guard batch.sequence > watermark else { return }
+ try conn.run("""
+ INSERT INTO rule_pack_event_watermarks
+ (delivery_session, sequence, received, dropped_hits, dropped_keys) VALUES (?,?,?,?,?)
+ ON CONFLICT(delivery_session) DO UPDATE SET
+ sequence = excluded.sequence, received = excluded.received,
+ dropped_hits = dropped_hits + excluded.dropped_hits,
+ dropped_keys = dropped_keys + excluded.dropped_keys
+ """, [.text(session), .int(batch.sequence), .int(Int64(Date().timeIntervalSince1970)),
+ .int(batch.droppedHits), .int(batch.droppedKeys)])
+ try recordRulePackEvents(batch.events, inTransaction: true)
+ inserted = true
+ }
+ return inserted
+ }
+
+ func recordRulePackEvents(_ events: [RulePackEvent]) throws {
+ try recordRulePackEvents(events, inTransaction: false)
+ }
+
+ private func recordRulePackEvents(_ events: [RulePackEvent], inTransaction: Bool) throws {
+ let sql = """
+ INSERT INTO rule_pack_event_rollups
+ (bucket, pack_id, pack_version, entry_id, category, match, action, count, first_at, last_at,
+ bundle_id, app_name, agent, agent_name, host, matched_host, port)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(bucket, pack_id, pack_version, entry_id, category, match, action, bundle_id, app_name,
+ agent, agent_name, host, matched_host, port) DO UPDATE SET
+ count = count + excluded.count,
+ first_at = MIN(first_at, excluded.first_at),
+ last_at = MAX(last_at, excluded.last_at)
+ """
+ let write = {
+ for event in events {
+ try self.conn.run(sql, [.int(event.bucket), .text(event.packID), .text(event.packVersion),
+ .int(Int64(event.entryID)), .text(event.category.rawValue),
+ .text(event.match.rawValue), .text(event.action.rawValue),
+ .int(Int64(event.count)), .int(event.firstAt), .int(event.lastAt),
+ .text(event.bundleID), .text(event.appName), .text(event.agentKey),
+ .text(event.agentName), .text(event.host), .text(event.matchedHost),
+ .int(Int64(event.port))])
+ }
+ }
+ if inTransaction { try write() }
+ else { try conn.transaction(write) }
+ }
+
+ func rulePackDeliveryStats() throws -> RulePackDeliveryStats {
+ let row = try conn.query("SELECT COALESCE(SUM(dropped_hits), 0), COALESCE(SUM(dropped_keys), 0) FROM rule_pack_event_watermarks") {
+ RulePackDeliveryStats(droppedHits: $0.int(0), droppedKeys: $0.int(1))
+ }.first
+ return row ?? RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0)
+ }
+
+ func rulePackEvents(packID: String? = nil, limit: Int = 300) throws -> [RulePackEventRecord] {
+ var sql = """
+ SELECT id, bucket, pack_id, pack_version, entry_id, category, match, action, count, first_at, last_at,
+ bundle_id, app_name, agent, agent_name, host, matched_host, port
+ FROM rule_pack_event_rollups
+ """
+ var values: [SQLValue] = []
+ if let packID { sql += " WHERE pack_id = ?"; values = [.text(packID)] }
+ sql += " ORDER BY last_at DESC, id DESC LIMIT \(max(1, limit))"
+ return try conn.query(sql, values) { row in
+ RulePackEventRecord(id: row.int(0), bucket: Date(timeIntervalSince1970: TimeInterval(row.int(1))),
+ packID: row.text(2), packVersion: row.text(3), entryID: UInt32(clamping: row.int(4)),
+ category: RulePackEntry.Category(rawValue: row.text(5)) ?? .tracking,
+ match: RulePackEntry.Match(rawValue: row.text(6)) ?? .suffix,
+ action: RulePackEvent.Action(rawValue: row.text(7)) ?? .block,
+ count: Int(row.int(8)),
+ firstAt: Date(timeIntervalSince1970: TimeInterval(row.int(9))),
+ lastAt: Date(timeIntervalSince1970: TimeInterval(row.int(10))),
+ bundleID: row.text(11), appName: row.text(12), agentKey: row.text(13),
+ agentName: row.text(14), host: row.text(15), matchedHost: row.text(16),
+ port: UInt16(clamping: row.int(17)))
+ }
+ }
+
+ func pruneRulePackEvents(before date: Date) throws {
+ let cutoff = Int64(date.timeIntervalSince1970)
+ try conn.transaction {
+ try conn.run("DELETE FROM rule_pack_event_rollups WHERE bucket < ?", [.int(cutoff)])
+ // Receipt IDs outlive visible activity: the extension may retry a committed batch after any disconnect.
+ // Keep deduplication durable, but stop old overflow counters contributing to the current warning.
+ try conn.run("UPDATE rule_pack_event_watermarks SET dropped_hits = 0, dropped_keys = 0 WHERE received < ?",
+ [.int(cutoff)])
+ }
+ }
+
// MARK: Devices on other channels
func recordDeviceEvents(_ events: [DeviceEvent]) throws {
@@ -1561,9 +1769,11 @@ final class TrafficDatabase: @unchecked Sendable {
func clearAll() throws {
try conn.transaction {
- for t in Self.tables + ["rollup_state", "seen_destinations", "seen_ports", "apps", "baselines", "alerts", "http_exchanges", "local_risk_assessments", "plugin_findings"] {
+ for t in Self.tables + ["rollup_state", "seen_destinations", "seen_ports", "apps", "baselines", "alerts", "http_exchanges", "local_risk_assessments", "plugin_findings", "rule_pack_event_rollups"] {
try conn.run("DELETE FROM \(t)")
}
+ // Keep event batch IDs so an acknowledgment lost before "Delete everything" cannot resurrect activity.
+ try conn.run("UPDATE rule_pack_event_watermarks SET dropped_hits = 0, dropped_keys = 0")
}
}
diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift
new file mode 100644
index 00000000..08995851
--- /dev/null
+++ b/Flowlight/UI/RulePacksView.swift
@@ -0,0 +1,369 @@
+import SwiftUI
+
+struct RulePacksView: View {
+ @EnvironmentObject var monitor: TrafficMonitor
+ @State private var editor: RulePackDefinition?
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 10) {
+ explanation
+ if monitor.rulePacks.deliveryStats.droppedHits > 0 {
+ Label(L("%lld managed-pack decisions were omitted from activity because the extension's bounded queue was full.",
+ monitor.rulePacks.deliveryStats.droppedHits), systemImage: "exclamationmark.triangle")
+ .font(.caption).foregroundStyle(FL.warning)
+ .padding(10).background(FL.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
+ }
+ ForEach(monitor.rulePacks.definitions) { pack in
+ card(pack)
+ }
+ }
+ .padding(Measure.gutter)
+ }
+ .sheet(item: $editor) { pack in RulePackEditor(pack: pack) }
+ }
+
+ private var explanation: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(L("Network ad and tracker blocking")).font(.headline)
+ Text(L("Official packs refuse connections to known destinations across apps. They are off until you enable them and need the Network Extension; they cannot remove page layout gaps, browser overlays, scripted popups, or native app windows."))
+ .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
+ }
+ .padding(.bottom, 4)
+ }
+
+ private func card(_ pack: RulePackDefinition) -> some View {
+ let configuration = monitor.rulePacks.configuration(for: pack.id)
+ let events = monitor.rulePacks.events(for: pack.id)
+ let blocks = events.reduce(0) { $0 + $1.count }
+ return VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: symbol(pack)).font(.title3).foregroundStyle(configuration.enabled ? FL.critical : .secondary)
+ .frame(width: 34, height: 34).background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
+ VStack(alignment: .leading, spacing: 3) {
+ Text(L(pack.manifest.titleKey)).font(.body.weight(.semibold))
+ Text(L(pack.manifest.summaryKey)).font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ Toggle("", isOn: Binding(get: { configuration.enabled }, set: { enabled in
+ if enabled { editor = pack }
+ else { var changed = configuration; changed.enabled = false; monitor.rulePacks.save(changed) }
+ })).toggleStyle(.switch).controlSize(.small).labelsHidden()
+ }
+ HStack(spacing: 8) {
+ pill(status(configuration), tint: statusTint(configuration))
+ pill(configuration.scope == .allApps ? L("Every app") : L("%lld selected apps", configuration.apps.count))
+ pill(L("%lld destinations", pack.entries.count))
+ if blocks > 0 { pill(L("%lld blocked", blocks), tint: FL.critical) }
+ if !monitor.rulePacks.overrides(for: pack.id).isEmpty {
+ pill(L("%lld exceptions", monitor.rulePacks.overrides(for: pack.id).count), tint: FL.good)
+ }
+ Spacer()
+ Button(L("Review…")) { editor = pack }
+ }
+ Text(L(pack.manifest.caveatKey)).font(.caption).foregroundStyle(.tertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ Text(L("Official · Built into Flowlight · pack v%@ · %@", pack.manifest.version, pack.manifest.license))
+ .font(.caption2).foregroundStyle(.tertiary)
+ }
+ .padding(14)
+ .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 11))
+ }
+
+ private func status(_ configuration: RulePackConfiguration) -> String {
+ if !configuration.enabled {
+ switch monitor.rulePacks.status {
+ case .applying: return L("Applying…")
+ case .failed: return L("Needs review")
+ default: return L("Off")
+ }
+ }
+ switch monitor.rulePacks.status {
+ case .off, .ready, .applying: return monitor.canBlock ? L("Applying…") : L("Watching only")
+ case .enforcing: return monitor.canBlock ? L("Blocking") : L("Watching only")
+ case .watchingOnly: return L("Watching only")
+ case .incompatible: return L("Needs extension update")
+ case .failed: return L("Needs review")
+ }
+ }
+
+ private func statusTint(_ configuration: RulePackConfiguration) -> Color? {
+ if !configuration.enabled {
+ switch monitor.rulePacks.status {
+ case .applying, .failed: return FL.warning
+ default: return nil
+ }
+ }
+ return monitor.canBlock && monitor.rulePacks.status == .enforcing ? FL.critical : FL.warning
+ }
+
+ private func symbol(_ pack: RulePackDefinition) -> String {
+ switch pack.entries.first?.category {
+ case .advertising: return "rectangle.badge.xmark"
+ case .tracking: return "point.3.connected.trianglepath.dotted"
+ case .telemetry: return "chart.bar.xaxis"
+ case nil: return "shield"
+ }
+ }
+
+ private func pill(_ text: String, tint: Color? = nil) -> some View {
+ Text(text).font(.caption2.weight(.medium)).foregroundStyle(tint ?? .secondary)
+ .padding(.horizontal, 6).padding(.vertical, 2)
+ .background((tint ?? Color.secondary).opacity(0.12), in: Capsule())
+ }
+}
+
+private struct RulePackEditor: View {
+ let pack: RulePackDefinition
+ @EnvironmentObject var monitor: TrafficMonitor
+ @Environment(\.dismiss) private var dismiss
+ @State private var configuration: RulePackConfiguration
+ @State private var originalConfiguration: RulePackConfiguration
+ @State private var app = ""
+ @State private var installed: [InstalledApps.App] = []
+ @State private var preview: RuleSimulation.Result?
+ @State private var previewing = false
+ @State private var confirmBroadening = false
+
+ init(pack: RulePackDefinition) {
+ self.pack = pack
+ let initial = RulePackConfiguration(packID: pack.id)
+ _configuration = State(initialValue: initial)
+ _originalConfiguration = State(initialValue: initial)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(L(pack.manifest.titleKey)).font(.title3.weight(.semibold))
+ Text(L(pack.manifest.summaryKey)).font(.callout).foregroundStyle(.secondary)
+ }.padding(Measure.gutter)
+ Divider()
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ scope
+ caveat
+ previewSection
+ destinations
+ exceptions
+ }.padding(Measure.gutter)
+ }
+ Divider()
+ HStack {
+ Button(L("Test against recent traffic")) { Task { await runPreview() } }
+ if previewing { ProgressView().controlSize(.small) }
+ Spacer()
+ Button(L("Cancel")) { dismiss() }
+ Button(L("Save")) { save() }
+ .keyboardShortcut(.defaultAction)
+ .disabled(configuration.scope == .selectedApps && configuration.apps.isEmpty)
+ }.padding(Measure.gutter)
+ }
+ .frame(width: Measure.prose)
+ .frame(minHeight: 620)
+ .onAppear {
+ let saved = monitor.rulePacks.configuration(for: pack.id)
+ configuration = saved
+ if !saved.enabled { configuration.enabled = true }
+ originalConfiguration = saved
+ Task { installed = await Task.detached(priority: .userInitiated) { InstalledApps.all() }.value }
+ }
+ .confirmationDialog(L("Apply this pack to every app?"), isPresented: $confirmBroadening) {
+ Button(L("Apply to every app"), role: .destructive) { commit() }
+ Button(L("Cancel"), role: .cancel) {}
+ } message: {
+ Text(L("This broadens blocking from selected apps to every app on this Mac. Essential system traffic remains allowed."))
+ }
+ }
+
+ private var scope: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text(L("Scope")).font(.headline)
+ Picker("", selection: $configuration.scope) {
+ Text(L("Every app")).tag(RulePackConfiguration.Scope.allApps)
+ Text(L("Only selected apps")).tag(RulePackConfiguration.Scope.selectedApps)
+ }.pickerStyle(.segmented).labelsHidden()
+ if configuration.scope == .selectedApps {
+ HStack {
+ TextField(L("App name, bundle identifier, or process"), text: $app)
+ Button(L("Add")) { addApp() }.disabled(app.trimmingCharacters(in: .whitespaces).isEmpty)
+ }
+ if !app.isEmpty, !appSuggestions.isEmpty {
+ VStack(alignment: .leading, spacing: 0) {
+ ForEach(appSuggestions) { suggestion in
+ Button {
+ addApp(suggestion.bundleID)
+ } label: {
+ HStack(spacing: 7) {
+ AppIconView(path: suggestion.path, size: 16)
+ Text(suggestion.name).font(.callout)
+ Text(suggestion.bundleID).font(.caption.monospaced())
+ .foregroundStyle(.secondary).lineLimit(1)
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ .padding(.vertical, 3)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ FlowLayout(spacing: 6) {
+ ForEach(configuration.apps, id: \.self) { id in
+ Button { configuration.apps.removeAll { $0 == id } } label: {
+ Label(id, systemImage: "xmark.circle.fill").font(.caption)
+ }.buttonStyle(.borderless)
+ }
+ }
+ }
+ }
+ }
+
+ private var caveat: some View {
+ Label(L(pack.manifest.caveatKey), systemImage: "exclamationmark.circle")
+ .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
+ .padding(10).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
+ }
+
+ @ViewBuilder private var previewSection: some View {
+ if let preview {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(L("Recent-traffic impact")).font(.headline)
+ if preview.connectionsConsidered == 0 {
+ Text(L("No retained traffic was available to test.")).font(.caption).foregroundStyle(.secondary)
+ } else if preview.isEmpty {
+ Text(L("Would have changed nothing across %lld retained connections.", preview.connectionsConsidered))
+ .font(.caption).foregroundStyle(FL.good)
+ } else {
+ Text(L("Would have refused %lld of %lld connections from %lld apps (%@).",
+ preview.connectionsRefused, preview.connectionsConsidered, preview.appsAffected,
+ ByteFormat.string(preview.bytesRefused))).font(.caption)
+ ForEach(preview.refusals.prefix(8)) { change in
+ HStack { Text(change.appName); Text(change.destination).font(.caption.monospaced()).foregroundStyle(.secondary); Spacer(); Text(L("%lld×", change.connections)).font(.caption) }
+ }
+ }
+ }
+ }
+ }
+
+ private var destinations: some View {
+ DisclosureGroup(L("%lld destinations", pack.entries.count)) {
+ VStack(alignment: .leading, spacing: 3) {
+ ForEach(pack.entries) { entry in
+ Text(entry.host).font(.caption.monospaced()).textSelection(.enabled)
+ }
+ }.padding(.top, 6)
+ }
+ }
+
+ @ViewBuilder private var exceptions: some View {
+ let found = monitor.rulePacks.overrides(for: pack.id)
+ if !found.isEmpty {
+ DisclosureGroup(L("%lld exceptions", found.count)) {
+ ForEach(found) { exception in
+ HStack {
+ Text(exception.app.isEmpty ? L("Every app") : exception.app).font(.caption)
+ Text("→").foregroundStyle(.tertiary)
+ Text(exception.host).font(.caption.monospaced())
+ Spacer()
+ Button(L("Delete"), role: .destructive) { monitor.rulePacks.deleteOverride(exception) }
+ }.padding(.vertical, 2)
+ }
+ }
+ }
+ }
+
+ private var appSuggestions: [InstalledApps.App] {
+ Array(InstalledApps.search(app, in: installed).filter { candidate in
+ !configuration.apps.contains { $0.caseInsensitiveCompare(candidate.bundleID) == .orderedSame }
+ }.prefix(5))
+ }
+
+ private func addApp(_ value: String? = nil) {
+ guard let target = InstalledApps.target(forTyped: value ?? app, apps: installed) else { return }
+ if !configuration.apps.contains(where: { $0.caseInsensitiveCompare(target.value) == .orderedSame }) {
+ configuration.apps.append(target.value); configuration.apps.sort()
+ }
+ app = ""
+ }
+
+ private func save() {
+ if originalConfiguration.enabled,
+ originalConfiguration.scope == .selectedApps,
+ configuration.scope == .allApps {
+ confirmBroadening = true
+ } else {
+ commit()
+ }
+ }
+
+ private func commit() {
+ monitor.rulePacks.save(configuration)
+ dismiss()
+ }
+
+ private func runPreview() async {
+ previewing = true
+ defer { previewing = false }
+ let input = try? await monitor.read {
+ (try $0.flowsForSimulation(since: Date().addingTimeInterval(-7 * 86400)), try $0.loadPolicies())
+ }
+ preview = RulePackSimulation.run(candidate: configuration, definitions: monitor.rulePacks.definitions,
+ configurations: monitor.rulePacks.configurations,
+ overrides: monitor.rulePacks.overrides, rules: monitor.rules.rules,
+ policies: input?.1 ?? [:], paused: monitor.rules.isPaused,
+ flows: input?.0 ?? [], session: RuleStore.session)
+ }
+}
+
+struct RulePackEventRow: View {
+ let event: RulePackEventRecord
+ @EnvironmentObject var monitor: TrafficMonitor
+ @EnvironmentObject var nav: AppNavigation
+ @State private var recentOverride: RulePackOverride?
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "rectangle.badge.xmark").font(.caption).foregroundStyle(FL.critical)
+ .frame(width: 14).padding(.top, 2)
+ VStack(alignment: .leading, spacing: 3) {
+ Text("\(event.host):\(event.port)").font(.callout.monospaced()).lineLimit(1)
+ Text(L("%@ · %@ pack v%@ matched %@ · %lld connections",
+ event.appName, packName, event.packVersion, event.matchedHost, event.count))
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ Spacer()
+ VStack(alignment: .trailing, spacing: 4) {
+ Text(event.lastAt.formatted(date: .omitted, time: .standard)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
+ if let recentOverride {
+ Button(L("Undo")) {
+ monitor.rulePacks.deleteOverride(recentOverride)
+ self.recentOverride = nil
+ }.buttonStyle(.link).font(.caption)
+ } else {
+ Menu {
+ Button(L("Allow for %@", event.appName)) { allowForApp() }
+ Button(L("Edit as normal rule…")) {
+ nav.writeRule(Rule(action: .allow, app: event.bundleID, destination: event.matchedHost))
+ }
+ } label: {
+ Text(L("Allow…"))
+ }
+ .menuStyle(.borderlessButton).fixedSize().font(.caption)
+ }
+ }
+ }.padding(.horizontal, 10).padding(.vertical, 8)
+ }
+
+ private func allowForApp() {
+ let exception = RulePackOverride(packID: event.packID, app: event.bundleID,
+ host: event.matchedHost, match: event.match)
+ recentOverride = exception
+ monitor.rulePacks.addOverride(exception)
+ }
+
+ private var packName: String {
+ monitor.rulePacks.definitions.first { $0.id == event.packID }.map { L($0.manifest.titleKey) } ?? event.packID
+ }
+}
diff --git a/Flowlight/UI/RulesView.swift b/Flowlight/UI/RulesView.swift
index 50f7715a..33ed64e7 100644
--- a/Flowlight/UI/RulesView.swift
+++ b/Flowlight/UI/RulesView.swift
@@ -28,18 +28,27 @@ struct RulesView: View {
}
private enum Tab: String, CaseIterable, Identifiable {
- case rules, activity
+ case rules, packs, activity
var id: String { rawValue }
- var title: String { self == .rules ? L("Rules") : L("What they did") }
+ var title: String {
+ switch self {
+ case .rules: return L("Rules")
+ case .packs: return L("Packs")
+ case .activity: return L("What they did")
+ }
+ }
}
private var store: RuleStore { monitor.rules }
var body: some View {
Group {
- if tab == .rules {
+ switch tab {
+ case .rules:
if store.rules.isEmpty { empty } else { ruleList }
- } else {
+ case .packs:
+ RulePacksView()
+ case .activity:
activityFeed
}
}
@@ -100,29 +109,31 @@ struct RulesView: View {
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
// Nothing to switch between until there is something in one of them.
- if !store.rules.isEmpty || !store.events.isEmpty {
+ if !store.rules.isEmpty || !store.events.isEmpty || !monitor.rulePacks.definitions.isEmpty {
ToolbarItem(placement: .principal) {
Picker("", selection: $tab) {
ForEach(Tab.allCases) { Text($0.title).tag($0) }
}
- .pickerStyle(.segmented).labelsHidden().frame(width: 220)
+ .pickerStyle(.segmented).labelsHidden().frame(width: 310)
}
}
ToolbarItemGroup {
pauseControl
- Button {
- sheet = .library(nil, RuleTemplate.Subject())
- } label: {
- Label(L("Templates"), systemImage: "square.grid.2x2")
- }
- .help(L("Rules worth having, grouped by what they are for, with the hosts already written out"))
- Button {
- sheet = .editor(Rule(), isNew: true)
- } label: {
- Label(L("Add Rule"), systemImage: "plus")
+ if tab == .rules {
+ Button {
+ sheet = .library(nil, RuleTemplate.Subject())
+ } label: {
+ Label(L("Templates"), systemImage: "square.grid.2x2")
+ }
+ .help(L("Rules worth having, grouped by what they are for, with the hosts already written out"))
+ Button {
+ sheet = .editor(Rule(), isNew: true)
+ } label: {
+ Label(L("Add Rule"), systemImage: "plus")
+ }
+ .keyboardShortcut("n", modifiers: .command)
+ .help(L("Write a rule: an app, an agent, a destination or a URL, blocked or allowed"))
}
- .keyboardShortcut("n", modifiers: .command)
- .help(L("Write a rule: an app, an agent, a destination or a URL, blocked or allowed"))
}
}
@@ -260,11 +271,11 @@ struct RulesView: View {
private var activityFeed: some View {
Group {
- if store.events.isEmpty {
+ if store.events.isEmpty && monitor.rulePacks.events.isEmpty {
ContentUnavailableView {
Label(L("Nothing yet"), systemImage: "list.bullet.rectangle")
} description: {
- Text(L("Every connection a rule decides lands here — the refusals, and the exceptions that let something through. The second half is how \"why is this getting through?\" stays answerable."))
+ Text(L("Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts."))
}
} else {
feed
@@ -277,6 +288,18 @@ struct RulesView: View {
private var feed: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 18) {
+ if !monitor.rulePacks.events.isEmpty {
+ VStack(alignment: .leading, spacing: 6) {
+ heading(L("Managed packs"))
+ VStack(spacing: 0) {
+ ForEach(Array(monitor.rulePacks.events.enumerated()), id: \.element.id) { index, event in
+ if index > 0 { Divider().padding(.leading, 34) }
+ RulePackEventRow(event: event)
+ }
+ }
+ .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10))
+ }
+ }
ForEach(feedDays) { day in
VStack(alignment: .leading, spacing: 6) {
heading(day.title)
diff --git a/Flowlight/UI/SharedStrings.swift b/Flowlight/UI/SharedStrings.swift
index fb429f5c..a385b912 100644
--- a/Flowlight/UI/SharedStrings.swift
+++ b/Flowlight/UI/SharedStrings.swift
@@ -11,6 +11,20 @@ import Foundation
/// but `scripts/i18n_extract.py` reads keys out of the source, so a key that is only ever a variable never
/// reaches the catalog and never gets translated. Spelling them out is what puts them in front of a translator.
+private var builtInRulePackLocalizationAnchors: [String] {
+ [
+ L("Advertising"),
+ L("Known advertising delivery and auction hosts across apps."),
+ L("Connection blocking cannot remove page layout gaps, first-party ads, or overlays drawn by a browser or app."),
+ L("Cross-site tracking"),
+ L("Common analytics, session replay, and cross-site measurement hosts."),
+ L("Some apps use the same providers for feature flags. They may fall back to built-in defaults when a host is unavailable."),
+ L("Privacy and telemetry"),
+ L("Crash reporting, install attribution, and audited Apple advertising and analytics hosts."),
+ L("Crash reports help developers fix failures. Essential Apple account, push, update, certificate, and iCloud traffic stays allowed."),
+ ]
+}
+
extension Guardrail {
/// `title`, in the reader's language. The parts that name things the user typed — an agent, a server, a
/// tool, a resource — stay exactly as they were typed.
diff --git a/FlowlightExtension/BlockEnforcer.swift b/FlowlightExtension/BlockEnforcer.swift
index 498bba01..282bc176 100644
--- a/FlowlightExtension/BlockEnforcer.swift
+++ b/FlowlightExtension/BlockEnforcer.swift
@@ -22,6 +22,11 @@ final class BlockEnforcer: @unchecked Sendable {
/// The rule list, which is asked before any allowlist: a rule is something someone wrote down about this
/// exact connection, and it should not be second-guessed by a policy written about a whole agent.
private var ruleSet = RuleSet()
+ /// One immutable, fully validated managed-pack generation. It is built away from the flow callback and swapped
+ /// under the same short lock as rules; a transfer in progress never appears here.
+ private var packMatcher: RulePackMatcher?
+ private var packGeneration: UUID?
+ private var packDigest = ""
/// How many decisions each rule has made this run, for the rules that stop after a set number ("allow once").
/// The app is the one that persists them; this is only what is needed to stop applying a spent rule.
private var ruleHits: [UUID: Int] = [:]
@@ -34,11 +39,13 @@ final class BlockEnforcer: @unchecked Sendable {
/// Called with every connection a rule decided, refusal and relaxation alike.
var reportDecision: (RuleEvent) -> Void = { _ in }
+ /// Pack hits are aggregated by `IPCServer`; this callback is intentionally not the ordinary block-alert path.
+ var reportPack: (RulePackEvent) -> Void = { _ in }
/// Asked before anything else on the data path: with no enforcing policy the filter does no extra work at all.
var isEnforcing: Bool {
lock.lock(); defer { lock.unlock() }
- return !policies.isEmpty || !ruleSet.rules.isEmpty
+ return !policies.isEmpty || !ruleSet.rules.isEmpty || packMatcher != nil
}
/// Replaces the enforcing set. The app sends this on connect and on every change, so it is always the whole
@@ -59,6 +66,32 @@ final class BlockEnforcer: @unchecked Sendable {
return set.rules.filter(\.enabled).count
}
+ func apply(_ snapshot: RulePackSnapshot) throws -> RulePackApplyResult {
+ let matcher = try RulePackMatcher(snapshot: snapshot)
+ lock.lock()
+ packMatcher = matcher
+ packGeneration = snapshot.generation
+ packDigest = snapshot.digest
+ lock.unlock()
+ return RulePackApplyResult(status: .committed, generation: snapshot.generation,
+ activeDigest: snapshot.digest, activePacks: snapshot.configurations.filter(\.enabled).count)
+ }
+
+ func clearPacks() -> RulePackApplyResult {
+ lock.lock()
+ packMatcher = nil
+ packGeneration = nil
+ packDigest = ""
+ lock.unlock()
+ return RulePackApplyResult(status: .cleared)
+ }
+
+ func packHandshake() -> RulePackHandshake {
+ lock.lock(); defer { lock.unlock() }
+ return RulePackHandshake(protocolVersion: RulePackSnapshot.protocolVersion,
+ activeGeneration: packGeneration, activeDigest: packDigest)
+ }
+
func appConnectionChanged(connected: Bool) {
lock.lock(); defer { lock.unlock() }
appConnected = connected
@@ -99,6 +132,26 @@ final class BlockEnforcer: @unchecked Sendable {
break
}
+ // A managed pack can only decide from a hostname. The first flow callback often arrives before TLS SNI or
+ // HTTP Host, so keep the flow unjudged until inspection either names it or reaches its bounded end. Without
+ // this, a flow with no matching AgentPolicy would be permanently waved through on its first callback.
+ if packNeedsHostname(facts) { return false }
+
+ if let match = packDecision(facts, process: state.process, agent: agent) {
+ state.judged = true
+ let minute = Int64(Date().timeIntervalSince1970) / 60 * 60
+ let now = Int64(Date().timeIntervalSince1970)
+ reportPack(RulePackEvent(bucket: minute, packID: match.packID, packVersion: match.packVersion,
+ entryID: match.entry.id, category: match.entry.category,
+ match: match.entry.match, action: .block,
+ count: 1, firstAt: now, lastAt: now, bundleID: facts.bundleID,
+ appName: state.process.name, agentKey: facts.agentKey,
+ agentName: agent?.name ?? state.process.name, host: match.matchedHost,
+ matchedHost: match.entry.host, port: facts.port))
+ extensionLog.info("Pack \(match.packID, privacy: .public) refused \(match.entry.host, privacy: .public)")
+ return true
+ }
+
guard let agent, let policy = policy(for: agent.key) else {
state.judged = true
return false
@@ -155,6 +208,23 @@ final class BlockEnforcer: @unchecked Sendable {
return decision
}
+ private func packNeedsHostname(_ facts: FlowFacts) -> Bool {
+ guard !facts.hostSettled, facts.host.isEmpty, inForce(), !BlockRules.isExempt(facts) else { return false }
+ lock.lock(); defer { lock.unlock() }
+ guard ruleSet.pausedUntil.map({ Date() >= $0 }) ?? true else { return false }
+ return packMatcher != nil
+ }
+
+ private func packDecision(_ facts: FlowFacts, process: ProcessInfoRecord, agent: Agent?) -> RulePackMatch? {
+ guard inForce(), !facts.host.isEmpty, !BlockRules.isExempt(facts) else { return nil }
+ lock.lock()
+ let matcher = packMatcher
+ let pausedUntil = ruleSet.pausedUntil
+ lock.unlock()
+ guard pausedUntil.map({ Date() >= $0 }) ?? true else { return nil }
+ return matcher?.matches(host: facts.host, bundleID: process.bundleID).first
+ }
+
private func policy(for key: String) -> AgentPolicy? {
lock.lock(); defer { lock.unlock() }
return policies[key]
diff --git a/FlowlightExtension/IPCServer.swift b/FlowlightExtension/IPCServer.swift
index 13f02d63..acf6b54d 100644
--- a/FlowlightExtension/IPCServer.swift
+++ b/FlowlightExtension/IPCServer.swift
@@ -17,7 +17,53 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
/// Connections a rule decided, on the same footing and for the same reason.
private var decisions: [RuleEvent] = []
private var decisionsInFlight = false
+ /// Managed-pack hits are coalesced before IPC. Every immutable explanation field participates so a snapshot
+ /// change cannot make an older decision appear to come from newer pack content or a different agent.
+ private struct PackEventKey: Hashable {
+ var bucket: Int64
+ var packID: String
+ var packVersion: String
+ var entryID: UInt32
+ var category: String
+ var match: String
+ var action: String
+ var bundleID: String
+ var appName: String
+ var agentKey: String
+ var agentName: String
+ var host: String
+ var matchedHost: String
+ var port: UInt16
+
+ init(_ event: RulePackEvent) {
+ bucket = event.bucket
+ packID = event.packID
+ packVersion = event.packVersion
+ entryID = event.entryID
+ category = event.category.rawValue
+ match = event.match.rawValue
+ action = event.action.rawValue
+ bundleID = event.bundleID
+ appName = event.appName
+ agentKey = event.agentKey
+ agentName = event.agentName
+ host = event.host
+ matchedHost = event.matchedHost
+ port = event.port
+ }
+ }
+ private var packEvents: [PackEventKey: RulePackEvent] = [:]
+ /// Removed from the mutable map before sending. New hits for the same key therefore cannot be erased by its ack.
+ private var packEventBatch: RulePackEventBatch?
+ private let packDeliverySession = UUID()
+ private var nextPackSequence: Int64 = 0
+ private var packEventsInFlight = false
+ private var packFlushScheduled = false
+ private var droppedPackHits: Int64 = 0
+ private var droppedPackKeys: Int64 = 0
+ private var packTransfer = RulePackTransferBuffer()
private let maxPendingBlocks = 500
+ private let maxPackEventKeys = 4_096
private let queue = DispatchQueue(label: "flowlight.ipc")
/// Seconds of traffic held while no app is connected — the whole time the app is on the nettop sampler, or
/// quit. An hour of it is worth keeping now that the newest second is delivered first (see `flushLocked`);
@@ -33,6 +79,7 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
func startListener() {
BlockEnforcer.shared.report = { [weak self] event in self?.report(event) }
BlockEnforcer.shared.reportDecision = { [weak self] event in self?.report(decision: event) }
+ BlockEnforcer.shared.reportPack = { [weak self] event in self?.report(pack: event) }
let listener = NSXPCListener(machServiceName: machServiceName)
listener.delegate = self
listener.resume()
@@ -57,6 +104,8 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
self.inFlight = false
self.blocksInFlight = false
self.decisionsInFlight = false
+ self.packEventsInFlight = false
+ self.packTransfer.reset()
BlockEnforcer.shared.appConnectionChanged(connected: false)
}
}
@@ -77,10 +126,17 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
self.inFlight = false
self.blocksInFlight = false
self.decisionsInFlight = false
- BlockEnforcer.shared.appConnectionChanged(connected: true)
+ self.packEventsInFlight = false
+ // A reconnect begins fail-open. The app may have disabled or narrowed any of these while disconnected;
+ // stale state must not become active during the policy/rule/pack synchronization that follows register.
+ _ = BlockEnforcer.shared.apply([])
+ _ = BlockEnforcer.shared.apply(RuleSet())
+ _ = BlockEnforcer.shared.clearPacks()
+ BlockEnforcer.shared.appConnectionChanged(connected: false)
self.flushLocked()
self.flushBlocksLocked()
self.flushDecisionsLocked()
+ self.flushPackEventsLocked()
}
reply(true, Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")
}
@@ -99,6 +155,68 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
reply(count)
}
+ func rulePackHello(appProtocol: Int, withReply reply: @escaping (Data) -> Void) {
+ reply(BlockCoding.encode(BlockEnforcer.shared.packHandshake()))
+ }
+
+ func beginRulePack(payload: Data, withReply reply: @escaping (Data) -> Void) {
+ queue.async {
+ guard let header = BlockCoding.decode(RulePackTransferHeader.self, from: payload) else {
+ reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "Unreadable pack transfer")))
+ return
+ }
+ let result = self.packTransfer.begin(header, active: BlockEnforcer.shared.packHandshake())
+ reply(BlockCoding.encode(result))
+ }
+ }
+
+ func appendRulePack(generation: String, index: Int, payload: Data, withReply reply: @escaping (Bool) -> Void) {
+ queue.async { reply(self.packTransfer.append(generation: generation, index: index, payload: payload)) }
+ }
+
+ func commitRulePack(generation: String, withReply reply: @escaping (Data) -> Void) {
+ queue.async {
+ switch self.packTransfer.commit(generation: generation, active: BlockEnforcer.shared.packHandshake()) {
+ case .rejected(let result):
+ reply(BlockCoding.encode(result))
+ case .payload(let data, let header):
+ guard let snapshot = BlockCoding.decode(RulePackSnapshot.self, from: data),
+ snapshot.generation == header.generation, snapshot.digest == header.digest else {
+ reply(BlockCoding.encode(RulePackApplyResult(status: .invalid,
+ detail: "Pack generation or digest mismatch")))
+ return
+ }
+ do {
+ let result = try BlockEnforcer.shared.apply(snapshot)
+ BlockEnforcer.shared.appConnectionChanged(connected: true)
+ reply(BlockCoding.encode(result))
+ } catch {
+ reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "\(error)")))
+ }
+ }
+ }
+ }
+
+ func clearRulePacks(payload: Data, withReply reply: @escaping (Data) -> Void) {
+ queue.async {
+ guard let request = BlockCoding.decode(RulePackClearRequest.self, from: payload) else {
+ reply(BlockCoding.encode(RulePackApplyResult(status: .invalid, detail: "Unreadable clear request")))
+ return
+ }
+ let active = BlockEnforcer.shared.packHandshake()
+ guard request.expectedActiveGeneration == active.activeGeneration else {
+ reply(BlockCoding.encode(RulePackApplyResult(status: .stale, generation: active.activeGeneration,
+ activeDigest: active.activeDigest,
+ detail: "The active pack generation changed.")))
+ return
+ }
+ self.packTransfer.reset()
+ let result = BlockEnforcer.shared.clearPacks()
+ BlockEnforcer.shared.appConnectionChanged(connected: true)
+ reply(BlockCoding.encode(result))
+ }
+ }
+
func send(_ batches: [TrafficBatch]) {
queue.async {
for batch in batches {
@@ -112,6 +230,29 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
}
}
+ func report(pack event: RulePackEvent) {
+ queue.async {
+ let key = PackEventKey(event)
+ if var existing = self.packEvents[key] {
+ existing.count += event.count
+ existing.firstAt = min(existing.firstAt, event.firstAt)
+ existing.lastAt = max(existing.lastAt, event.lastAt)
+ self.packEvents[key] = existing
+ } else if self.packEvents.count < self.maxPackEventKeys {
+ self.packEvents[key] = event
+ } else {
+ self.droppedPackHits = self.saturatingAdd(self.droppedPackHits, Int64(event.count))
+ self.droppedPackKeys = self.saturatingAdd(self.droppedPackKeys, 1)
+ }
+ guard !self.packFlushScheduled else { return }
+ self.packFlushScheduled = true
+ self.queue.asyncAfter(deadline: .now() + 1) {
+ self.packFlushScheduled = false
+ self.flushPackEventsLocked()
+ }
+ }
+ }
+
/// A connection the filter refused, on its way to being recorded as an alert.
func report(_ event: BlockEvent) {
queue.async {
@@ -134,6 +275,57 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
}
}
+ private func flushPackEventsLocked() {
+ guard !packEventsInFlight, let client else { return }
+ if packEventBatch == nil {
+ guard !packEvents.isEmpty || droppedPackHits > 0 else { return }
+ let keys = Array(packEvents.keys.prefix(300))
+ let events = keys.compactMap { packEvents.removeValue(forKey: $0) }
+ nextPackSequence = saturatingAdd(nextPackSequence, 1)
+ packEventBatch = RulePackEventBatch(deliverySession: packDeliverySession, sequence: nextPackSequence,
+ events: events, droppedHits: droppedPackHits,
+ droppedKeys: droppedPackKeys)
+ droppedPackHits = 0
+ droppedPackKeys = 0
+ }
+ guard let batch = packEventBatch else { return }
+ let capturedClient = client
+ let proxy = capturedClient.remoteObjectProxyWithErrorHandler { [weak self, weak capturedClient] error in
+ extensionLog.error("rulePackEvents() failed: \(error.localizedDescription, privacy: .public)")
+ self?.queue.async {
+ guard let self, self.client === capturedClient, self.packEventBatch?.id == batch.id else { return }
+ self.packEventsInFlight = false
+ self.schedulePackRetryLocked()
+ }
+ } as? FlowlightAppXPC
+ guard let proxy else {
+ schedulePackRetryLocked()
+ return
+ }
+ packEventsInFlight = true
+ proxy.rulePackEvents(payload: BlockCoding.encode(batch)) { [weak self, weak capturedClient] accepted in
+ self?.queue.async {
+ guard let self, self.client === capturedClient, self.packEventBatch?.id == batch.id else { return }
+ self.packEventsInFlight = false
+ if accepted {
+ self.packEventBatch = nil
+ self.flushPackEventsLocked()
+ } else {
+ self.schedulePackRetryLocked()
+ }
+ }
+ }
+ }
+
+ private func schedulePackRetryLocked() {
+ queue.asyncAfter(deadline: .now() + 1) { [weak self] in self?.flushPackEventsLocked() }
+ }
+
+ private func saturatingAdd(_ lhs: Int64, _ rhs: Int64) -> Int64 {
+ let (sum, overflow) = lhs.addingReportingOverflow(rhs)
+ return overflow ? Int64.max : sum
+ }
+
private func flushDecisionsLocked() {
guard !decisionsInFlight, let client, !decisions.isEmpty else { return }
let chunk = decisions
diff --git a/FlowlightExtension/Info.plist b/FlowlightExtension/Info.plist
index 1243698e..2a1cd533 100644
--- a/FlowlightExtension/Info.plist
+++ b/FlowlightExtension/Info.plist
@@ -20,17 +20,17 @@
content filter at a time and holds superseded copies until a restart, so changing the extension's version
on every app release forced a reboot each time. Kept fixed, an app-only update ships the version macOS
already runs and replaces nothing. Bump these two ONLY when the FlowlightExtension binary itself changes
- (and expect one replacement when you do). Kept at the 0.10.1 values so upgrades from 0.10.1 need no reboot. -->
+ (and expect one replacement when you do). 0.15.0 adds managed rule-pack enforcement and IPC. -->
CFBundleShortVersionString
- 0.10.1
+ 0.15.0CFBundleVersion
- 22
+ 23LSMinimumSystemVersion$(MACOSX_DEPLOYMENT_TARGET)FLAppGroupIdentifier$(TeamIdentifierPrefix)com.flowlight.sharedNSSystemExtensionUsageDescription
- Flowlight uses a network filter extension to attribute every connection to the app that made it. It records only metadata, never blocks traffic, and nothing leaves your Mac.
+ Flowlight uses a network filter extension to attribute connections to apps and can refuse only the rules and managed packs you enable. It records metadata locally, and nothing leaves your Mac.NetworkExtensionNEMachServiceName
diff --git a/FlowlightTests/BlockingTests.swift b/FlowlightTests/BlockingTests.swift
index 9fb77128..9d3e150c 100644
--- a/FlowlightTests/BlockingTests.swift
+++ b/FlowlightTests/BlockingTests.swift
@@ -38,6 +38,10 @@ final class BlockingTests: XCTestCase {
XCTAssertEqual(BlockRules.verdict(for: facts(bundleID: "com.flowlight.app"), policy: policy()), .allow,
"Flowlight's own traffic")
XCTAssertEqual(BlockRules.verdict(for: facts(bundleID: "com.apple.nsurlsessiond"), policy: policy()), .allow)
+ XCTAssertEqual(BlockRules.verdict(for: facts(bundleID: "com.apple.Safari"), policy: policy()), .block,
+ "user-facing Apple apps are not a blanket safety exemption")
+ XCTAssertEqual(BlockRules.verdict(for: facts(host: "advertising.apple.com"), policy: policy()), .block,
+ "audited Apple advertising hosts remain blockable")
XCTAssertEqual(BlockRules.verdict(for: facts(host: "gateway.icloud.com"), policy: policy()), .allow,
"Apple services")
XCTAssertEqual(BlockRules.verdict(for: facts(host: "", ip: "1.1.1.1", port: 53), policy: policy()), .allow,
diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift
new file mode 100644
index 00000000..640a898e
--- /dev/null
+++ b/FlowlightTests/RulePackTests.swift
@@ -0,0 +1,429 @@
+import XCTest
+@testable import Flowlight
+
+final class RulePackTests: XCTestCase {
+ func testCanonicalizesCaseTrailingDotUnicodeAndAddresses() {
+ XCTAssertEqual(HostCanonicalizer.host(" Example.COM. "), "example.com")
+ XCTAssertEqual(HostCanonicalizer.host("bücher.example"), "xn--bcher-kva.example")
+ XCTAssertEqual(HostCanonicalizer.host("2001:0db8::1"), "2001:db8::1")
+ XCTAssertNil(HostCanonicalizer.host("-bad.example"))
+ XCTAssertNil(HostCanonicalizer.host("two..dots.example"))
+ }
+
+ func testSuffixAndExactEntriesHaveDifferentReach() throws {
+ let definition = pack(entries: [
+ RulePackEntry(id: 1, host: "ads.example", match: .suffix, category: .advertising),
+ RulePackEntry(id: 2, host: "exact.example", match: .exact, category: .tracking),
+ ])
+ let matcher = try RulePackMatcher(snapshot: snapshot(definition))
+ XCTAssertEqual(matcher.matches(host: "cdn.ads.example", bundleID: "com.example.app").map(\.entry.id), [1])
+ XCTAssertEqual(matcher.matches(host: "exact.example", bundleID: "com.example.app").map(\.entry.id), [2])
+ XCTAssertTrue(matcher.matches(host: "sub.exact.example", bundleID: "com.example.app").isEmpty)
+ }
+
+ func testSelectedAppScopeUsesConnectionOpeningBundleID() throws {
+ let definition = pack(entries: [RulePackEntry(id: 1, host: "ads.example", category: .advertising)])
+ let value = RulePackSnapshot.make(
+ definitions: [definition],
+ configurations: [RulePackConfiguration(packID: definition.id, enabled: true,
+ scope: .selectedApps, apps: ["com.apple.Safari"])],
+ overrides: [])
+ let matcher = try RulePackMatcher(snapshot: value)
+ XCTAssertEqual(matcher.matches(host: "ads.example", bundleID: "com.apple.Safari").count, 1)
+ XCTAssertTrue(matcher.matches(host: "ads.example", bundleID: "com.google.Chrome").isEmpty)
+ }
+
+ func testExactPackOverrideDoesNotSuppressSubdomains() throws {
+ let definition = pack(entries: [
+ RulePackEntry(id: 1, host: "ads.example", match: .suffix, category: .advertising),
+ ])
+ let value = RulePackSnapshot.make(
+ definitions: [definition],
+ configurations: [RulePackConfiguration(packID: definition.id, enabled: true)],
+ overrides: [RulePackOverride(packID: definition.id, app: "com.example.app",
+ host: "ads.example", match: .exact)])
+ let matcher = try RulePackMatcher(snapshot: value)
+ XCTAssertTrue(matcher.matches(host: "ads.example", bundleID: "com.example.app").isEmpty)
+ XCTAssertEqual(matcher.matches(host: "cdn.ads.example", bundleID: "com.example.app").count, 1)
+ }
+
+ func testPackAllowOverrideSuppressesOnlyThatPack() throws {
+ let first = pack(id: "official.ads", entries: [
+ RulePackEntry(id: 1, host: "ads.example", category: .advertising),
+ ])
+ let second = pack(id: "official.tracking", entries: [
+ RulePackEntry(id: 1, host: "ads.example", category: .tracking),
+ ])
+ let value = RulePackSnapshot.make(
+ definitions: [first, second],
+ configurations: [RulePackConfiguration(packID: first.id, enabled: true),
+ RulePackConfiguration(packID: second.id, enabled: true)],
+ overrides: [RulePackOverride(packID: first.id, app: "com.example.app", host: "ads.example")])
+ let matches = try RulePackMatcher(snapshot: value).matches(host: "cdn.ads.example", bundleID: "com.example.app")
+ XCTAssertEqual(matches.map(\.packID), [second.id])
+ }
+
+ func testValidatorRejectsTamperDuplicateAndProtocolMismatch() throws {
+ let valid = pack(entries: [RulePackEntry(id: 1, host: "ads.example", category: .advertising)])
+ XCTAssertNoThrow(try RulePackValidator.validate([valid]))
+
+ var tampered = valid
+ tampered.entries[0].host = "changed.example"
+ XCTAssertThrowsError(try RulePackValidator.validate([tampered])) {
+ XCTAssertEqual($0 as? RulePackValidationError, .digestMismatch(valid.id))
+ }
+
+ let duplicate = pack(entries: [
+ RulePackEntry(id: 1, host: "ads.example", category: .advertising),
+ RulePackEntry(id: 2, host: "ads.example", category: .advertising),
+ ])
+ XCTAssertThrowsError(try RulePackValidator.validate([duplicate]))
+
+ var future = valid
+ future.manifest.minimumExtensionProtocol = RulePackSnapshot.protocolVersion + 1
+ XCTAssertThrowsError(try RulePackValidator.validate([future])) {
+ XCTAssertEqual($0 as? RulePackValidationError, .incompatibleProtocol(valid.id))
+ }
+
+ var newerApp = valid
+ newerApp.manifest.minimumAppVersion = "0.15.1"
+ XCTAssertThrowsError(try RulePackValidator.validate([newerApp], currentAppVersion: "0.15.0")) {
+ XCTAssertEqual($0 as? RulePackValidationError, .incompatibleProtocol(valid.id))
+ }
+ }
+
+ func testSimulationReportsOnlyNewlyBlockedTraffic() throws {
+ let definition = pack(entries: [RulePackEntry(id: 1, host: "ads.example", category: .advertising)])
+ let candidate = RulePackConfiguration(packID: definition.id)
+ let flow = RuleSimulation.Flow(when: Date(), agentKey: "com.apple.Safari", bundleID: "com.apple.Safari",
+ appName: "Safari", host: "cdn.ads.example", ip: "203.0.113.7", port: 443,
+ bytes: 50, connections: 2)
+ let result = RulePackSimulation.run(candidate: candidate, definitions: [definition], configurations: [],
+ overrides: [], rules: [], flows: [flow])
+ XCTAssertEqual(result.connectionsRefused, 2)
+
+ let alreadyBlocked = Rule(action: .block, destination: "ads.example")
+ let unchanged = RulePackSimulation.run(candidate: candidate, definitions: [definition], configurations: [],
+ overrides: [], rules: [alreadyBlocked], flows: [flow])
+ XCTAssertTrue(unchanged.isEmpty)
+ }
+
+ func testSimulationUsesSafetyPauseRulesAndAgentPolicyPrecedence() {
+ let definition = pack(entries: [
+ RulePackEntry(id: 1, host: "ads.example", category: .advertising),
+ RulePackEntry(id: 2, host: "icloud.com", category: .telemetry),
+ ])
+ let candidate = RulePackConfiguration(packID: definition.id)
+ let ad = RuleSimulation.Flow(when: Date(), agentKey: "claude", bundleID: "curl", appName: "curl",
+ host: "ads.example", ip: "203.0.113.7", port: 443,
+ bytes: 50, connections: 2)
+ let essential = RuleSimulation.Flow(when: Date(), agentKey: "com.apple.Safari",
+ bundleID: "com.apple.Safari", appName: "Safari",
+ host: "gateway.icloud.com", ip: "17.0.0.1", port: 443,
+ bytes: 20, connections: 1)
+
+ let exempt = RulePackSimulation.run(candidate: candidate, definitions: [definition], configurations: [],
+ overrides: [], rules: [], flows: [essential])
+ XCTAssertTrue(exempt.isEmpty)
+
+ let paused = RulePackSimulation.run(candidate: candidate, definitions: [definition], configurations: [],
+ overrides: [], rules: [], paused: true, flows: [ad])
+ XCTAssertTrue(paused.isEmpty)
+
+ let policy = AgentPolicy(agentID: "claude", allowAIProviders: false, patterns: [], enforce: true)
+ let policyAlreadyBlocks = RulePackSimulation.run(candidate: candidate, definitions: [definition],
+ configurations: [], overrides: [], rules: [],
+ policies: ["claude": policy], flows: [ad])
+ XCTAssertTrue(policyAlreadyBlocks.isEmpty)
+
+ let explicitAllow = Rule(action: .allow, app: "curl", destination: "ads.example")
+ let userAllowWins = RulePackSimulation.run(candidate: candidate, definitions: [definition],
+ configurations: [], overrides: [], rules: [explicitAllow],
+ policies: ["claude": policy], flows: [ad])
+ XCTAssertTrue(userAllowWins.isEmpty)
+ }
+
+ func testConfigurationOverridesAndAggregatedEventsPersist() throws {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let configuration = RulePackConfiguration(packID: "official.ads", enabled: true,
+ scope: .selectedApps, apps: ["com.apple.Safari"])
+ let override = RulePackOverride(packID: "official.ads", app: "com.apple.Safari", host: "ads.example")
+ try db.saveRulePackConfiguration(configuration)
+ try db.saveRulePackOverride(override)
+ try db.recordRulePackEvents([
+ RulePackEvent(bucket: 1_700_000_000, packID: "official.ads", packVersion: "1", entryID: 7,
+ category: .advertising, action: .block, count: 3, firstAt: 1_700_000_001,
+ lastAt: 1_700_000_020, bundleID: "com.apple.Safari", appName: "Safari",
+ agentKey: "com.apple.Safari", agentName: "Safari", host: "cdn.ads.example",
+ matchedHost: "ads.example", port: 443),
+ RulePackEvent(bucket: 1_700_000_000, packID: "official.ads", packVersion: "1", entryID: 7,
+ category: .advertising, action: .block, count: 2, firstAt: 1_700_000_021,
+ lastAt: 1_700_000_030, bundleID: "com.apple.Safari", appName: "Safari",
+ agentKey: "com.apple.Safari", agentName: "Safari", host: "cdn.ads.example",
+ matchedHost: "ads.example", port: 443),
+ ])
+
+ let batch = RulePackEventBatch(id: UUID(), events: [
+ RulePackEvent(bucket: 1_700_000_060, packID: "official.ads", packVersion: "1", entryID: 8,
+ category: .advertising, action: .block, count: 4, firstAt: 1_700_000_061,
+ lastAt: 1_700_000_070, bundleID: "com.apple.Safari", appName: "Safari",
+ agentKey: "com.apple.Safari", agentName: "Safari", host: "ads.example",
+ matchedHost: "ads.example", port: 443),
+ ], droppedHits: 2, droppedKeys: 1)
+ XCTAssertTrue(try db.recordRulePackEventBatch(batch))
+ XCTAssertFalse(try db.recordRulePackEventBatch(batch), "a retry is acknowledged without applying counts twice")
+ XCTAssertEqual(try db.rulePackDeliveryStats(), RulePackDeliveryStats(droppedHits: 2, droppedKeys: 1))
+
+ let reopened = try TrafficDatabase(url: url)
+ XCTAssertEqual(try reopened.loadRulePackConfigurations(), [configuration])
+ XCTAssertEqual(try reopened.loadRulePackOverrides(), [override])
+ let events = try reopened.rulePackEvents()
+ let event = events.first { $0.entryID == 7 }
+ XCTAssertEqual(event?.count, 5)
+ XCTAssertEqual(event?.matchedHost, "ads.example")
+ XCTAssertEqual(event?.lastAt, Date(timeIntervalSince1970: 1_700_000_030))
+ XCTAssertEqual(events.first { $0.entryID == 8 }?.count, 4)
+ try reopened.deleteRulePackOverride(override.id)
+ XCTAssertTrue(try reopened.loadRulePackOverrides().isEmpty)
+ }
+
+ @MainActor
+ func testBuiltInPacksValidateAndDefaultOff() throws {
+ XCTAssertNoThrow(try RulePackValidator.validate(BuiltInRulePacks.all))
+ let store = RulePackStore()
+ let definitionCount = store.definitions.count
+ let enabledCount = store.enabledCount
+ let snapshot = store.snapshot
+ XCTAssertEqual(definitionCount, 3)
+ XCTAssertEqual(enabledCount, 0)
+ XCTAssertNil(snapshot)
+ }
+
+ @MainActor
+ func testStorePublishesOnlyAfterConfigurationCommit() async throws {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-store-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = RulePackStore()
+ store.attach(db: db)
+ try await waitUntil { store.configurations.count == store.definitions.count }
+
+ var publications = [RulePackSnapshot?]()
+ store.onChange = { publications.append($0) }
+ var enabled = store.configuration(for: "official.advertising")
+ enabled.enabled = true
+ store.save(enabled)
+
+ XCTAssertTrue(publications.isEmpty)
+ try await waitUntil { publications.count == 1 }
+ let saved = try db.sync { try $0.loadRulePackConfigurations() }
+ XCTAssertEqual(saved.count, 1)
+ XCTAssertEqual(saved[0].packID, enabled.packID)
+ XCTAssertEqual(saved[0].consentedVersion, BuiltInRulePacks.advertising.manifest.version)
+ XCTAssertEqual(saved[0].consentedContentSHA256, BuiltInRulePacks.advertising.manifest.contentSHA256)
+ XCTAssertEqual(publications[0]?.configurations, saved)
+ }
+
+ @MainActor
+ func testStorePublishesAfterTransientFailureRecovers() async throws {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-recovery-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = RulePackStore()
+ store.attach(db: db)
+ try await waitUntil { store.configurations.count == store.definitions.count }
+
+ var publications = [RulePackSnapshot?]()
+ store.onChange = { publications.append($0) }
+ store.updateStatus(.failed("transient storage failure"))
+ var enabled = store.configuration(for: "official.advertising")
+ enabled.enabled = true
+ store.save(enabled)
+
+ try await waitUntil { publications.count == 1 }
+ let saved = try db.sync { try $0.loadRulePackConfigurations() }
+ XCTAssertEqual(saved.count, 1)
+ XCTAssertEqual(saved[0].packID, enabled.packID)
+ XCTAssertEqual(publications.count, 1, "a successful retry must publish after a transient failure")
+ XCTAssertEqual(store.status, .ready)
+ }
+
+ @MainActor
+ func testClearingStoreActivityKeepsConfigurationAndExceptions() async throws {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-clear-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let store = RulePackStore()
+ store.attach(db: db)
+ try await waitUntil { store.configurations.count == store.definitions.count }
+
+ var configuration = store.configuration(for: "official.advertising")
+ configuration.enabled = true
+ store.save(configuration)
+ let exception = RulePackOverride(packID: configuration.packID, app: "com.apple.Safari", host: "ads.example")
+ store.addOverride(exception)
+ let batch = RulePackEventBatch(events: [
+ RulePackEvent(bucket: 1_700_000_000, packID: configuration.packID, packVersion: "1", entryID: 1,
+ category: .advertising, action: .block, count: 2, firstAt: 1_700_000_001,
+ lastAt: 1_700_000_002, bundleID: "com.apple.Safari", appName: "Safari",
+ agentKey: "com.apple.Safari", agentName: "Safari", host: "ads.example",
+ matchedHost: "ads.example", port: 443),
+ ], droppedHits: 3, droppedKeys: 1)
+ await withCheckedContinuation { continuation in
+ store.record(batch) { _ in continuation.resume() }
+ }
+ try await waitUntil { !store.events.isEmpty && !store.overrides.isEmpty }
+
+ try await waitUntil { store.configuration(for: configuration.packID).enabled }
+ configuration = store.configuration(for: configuration.packID)
+ store.clearActivity()
+
+ XCTAssertTrue(store.events.isEmpty)
+ XCTAssertEqual(store.deliveryStats, RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0))
+ XCTAssertEqual(store.configuration(for: configuration.packID), configuration)
+ XCTAssertEqual(store.overrides, [exception])
+ }
+
+ @MainActor
+ func testChangedPackRequiresFreshConsent() async throws {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-consent-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ var original = BuiltInRulePacks.advertising
+ var configuration = RulePackConfiguration(packID: original.id, enabled: true)
+ configuration.consentedVersion = original.manifest.version
+ configuration.consentedContentSHA256 = original.manifest.contentSHA256
+ try db.saveRulePackConfiguration(configuration)
+
+ original.entries.append(RulePackEntry(id: UInt32(original.entries.count + 1), host: "new-ad.example",
+ category: .advertising))
+ original.entries.sort { ("\($0.host)|\($0.match.rawValue)") < ("\($1.host)|\($1.match.rawValue)") }
+ original.manifest.version = "2"
+ original.manifest.entryCount = original.entries.count
+ original.manifest.contentSHA256 = RulePackValidator.digest(original.entries)
+ let store = RulePackStore(definitions: [original])
+ store.attach(db: db)
+ try await waitUntil { store.configurations.count == 1 }
+
+ XCTAssertFalse(store.configuration(for: original.id).enabled)
+ XCTAssertNil(store.snapshot)
+ }
+
+ func testActivityKeepsPackProvenanceSeparateAndReceiptAfterPrune() throws {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-provenance-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let batch = RulePackEventBatch(events: [
+ event(version: "1", category: .advertising, matchedHost: "ads-v1.example", agentKey: "first-agent"),
+ event(version: "2", category: .tracking, matchedHost: "ads-v2.example", agentKey: "second-agent"),
+ ], droppedHits: 4, droppedKeys: 2)
+ XCTAssertTrue(try db.recordRulePackEventBatch(batch))
+ XCTAssertEqual(try db.rulePackEvents().count, 2)
+
+ try db.pruneRulePackEvents(before: Date().addingTimeInterval(1))
+ XCTAssertTrue(try db.rulePackEvents().isEmpty)
+ XCTAssertEqual(try db.rulePackDeliveryStats(), RulePackDeliveryStats(droppedHits: 0, droppedKeys: 0))
+ XCTAssertFalse(try db.recordRulePackEventBatch(batch), "a pruned batch watermark must still deduplicate retry")
+ XCTAssertTrue(try db.rulePackEvents().isEmpty)
+ }
+
+ func testTransferBufferIsBoundedIdempotentAndRejectsStaleCommit() throws {
+ let first = UUID()
+ let next = UUID()
+ let empty = RulePackHandshake(protocolVersion: RulePackSnapshot.protocolVersion,
+ activeGeneration: nil, activeDigest: "")
+ let header = RulePackTransferHeader(protocolVersion: RulePackSnapshot.protocolVersion,
+ generation: first, digest: "digest", totalBytes: 6, chunks: 2,
+ expectedActiveGeneration: nil)
+ var buffer = RulePackTransferBuffer(maxBytes: 6, maxChunks: 2)
+ XCTAssertEqual(buffer.begin(header, active: empty).status, .accepted)
+ XCTAssertTrue(buffer.append(generation: first.uuidString, index: 0, payload: Data("abc".utf8)))
+ XCTAssertTrue(buffer.append(generation: first.uuidString, index: 0, payload: Data("abc".utf8)),
+ "an identical retry is idempotent")
+ XCTAssertEqual(buffer.receivedBytes, 3)
+ XCTAssertTrue(buffer.append(generation: first.uuidString, index: 0, payload: Data("ab".utf8)),
+ "a replacement is charged by its net size")
+ XCTAssertEqual(buffer.receivedBytes, 2)
+ XCTAssertFalse(buffer.append(generation: first.uuidString, index: 1, payload: Data("12345".utf8)),
+ "the declared byte count is a hard bound")
+ XCTAssertTrue(buffer.append(generation: first.uuidString, index: 1, payload: Data("1234".utf8)))
+
+ let changed = RulePackHandshake(protocolVersion: RulePackSnapshot.protocolVersion,
+ activeGeneration: next, activeDigest: "new")
+ guard case .rejected(let stale) = buffer.commit(generation: first.uuidString, active: changed) else {
+ return XCTFail("a stale commit must be rejected")
+ }
+ XCTAssertEqual(stale.status, .stale)
+ XCTAssertNil(buffer.header)
+ }
+
+ func testTransferBufferRejectsIncompleteAndReturnsOrderedPayload() throws {
+ let generation = UUID()
+ let active = RulePackHandshake(protocolVersion: RulePackSnapshot.protocolVersion,
+ activeGeneration: nil, activeDigest: "")
+ let header = RulePackTransferHeader(protocolVersion: RulePackSnapshot.protocolVersion,
+ generation: generation, digest: "digest", totalBytes: 6, chunks: 2,
+ expectedActiveGeneration: nil)
+ var buffer = RulePackTransferBuffer(maxBytes: 6, maxChunks: 2)
+ XCTAssertEqual(buffer.begin(header, active: active).status, .accepted)
+ XCTAssertTrue(buffer.append(generation: generation.uuidString, index: 1, payload: Data("def".utf8)))
+ guard case .rejected(let incomplete) = buffer.commit(generation: generation.uuidString, active: active) else {
+ return XCTFail("an incomplete transfer must reply with a rejection")
+ }
+ XCTAssertEqual(incomplete.status, .invalid)
+
+ XCTAssertEqual(buffer.begin(header, active: active).status, .accepted)
+ XCTAssertTrue(buffer.append(generation: generation.uuidString, index: 1, payload: Data("def".utf8)))
+ XCTAssertTrue(buffer.append(generation: generation.uuidString, index: 0, payload: Data("abc".utf8)))
+ guard case .payload(let data, _) = buffer.commit(generation: generation.uuidString, active: active) else {
+ return XCTFail("the complete transfer should assemble")
+ }
+ XCTAssertEqual(String(decoding: data, as: UTF8.self), "abcdef")
+ }
+
+ func testOneHundredThousandEntriesRemainIndexed() throws {
+ let entries = (0..<100_000).map {
+ RulePackEntry(id: UInt32($0 + 1), host: String(format: "ad%06d.example", $0),
+ match: .suffix, category: .advertising)
+ }
+ let definition = pack(entries: entries)
+ let matcher = try RulePackMatcher(snapshot: snapshot(definition))
+ measure {
+ for value in stride(from: 0, to: 100_000, by: 1_000) {
+ XCTAssertEqual(matcher.matches(host: String(format: "cdn.ad%06d.example", value),
+ bundleID: "com.example.app").count, 1)
+ }
+ }
+ }
+
+ private func event(version: String, category: RulePackEntry.Category, matchedHost: String,
+ agentKey: String) -> RulePackEvent {
+ RulePackEvent(bucket: 1_700_000_000, packID: "official.ads", packVersion: version, entryID: 1,
+ category: category, action: .block, count: 1, firstAt: 1_700_000_001,
+ lastAt: 1_700_000_002, bundleID: "com.apple.Safari", appName: "Safari",
+ agentKey: agentKey, agentName: agentKey, host: "ads.example",
+ matchedHost: matchedHost, port: 443)
+ }
+
+ @MainActor
+ private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async throws {
+ let deadline = Date().addingTimeInterval(2)
+ while !condition() {
+ guard Date() < deadline else { throw WaitError.timedOut }
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ }
+
+ private enum WaitError: Error { case timedOut }
+
+ private func pack(id: String = "official.ads", entries: [RulePackEntry]) -> RulePackDefinition {
+ let sorted = entries.sorted { ("\($0.host)|\($0.match.rawValue)") < ("\($1.host)|\($1.match.rawValue)") }
+ let manifest = RulePackManifest(id: id, version: "1", generatedAt: Date(timeIntervalSince1970: 0),
+ entryCount: sorted.count, contentSHA256: RulePackValidator.digest(sorted),
+ titleKey: "pack.\(id).title", summaryKey: "pack.\(id).summary",
+ caveatKey: "pack.\(id).caveat", source: "Flowlight", license: "GPL-3.0")
+ return RulePackDefinition(manifest: manifest, entries: sorted)
+ }
+
+ private func snapshot(_ definitions: RulePackDefinition...) -> RulePackSnapshot {
+ RulePackSnapshot.make(definitions: definitions,
+ configurations: definitions.map { RulePackConfiguration(packID: $0.id, enabled: true) },
+ overrides: [])
+ }
+}
diff --git a/README.md b/README.md
index f23fa7e3..5d6f5739 100644
--- a/README.md
+++ b/README.md
@@ -128,7 +128,7 @@ An allowlist warns by default. Turn on **Refuse connections outside the list** f
connections as well, recording each one as an alert with **Allow … from Now On** to add it to the list in a click.
It needs the Network Extension — refusing a connection means being in its path, which the sampler can't do. Local
-traffic, DNS, Apple services and Flowlight's own connections are never refused, enforcement lapses a few seconds
+traffic, DNS, essential Apple system services and Flowlight's own connections are never refused, enforcement lapses a few seconds
after the app stops running, and HTTPS inspection doesn't combine with it.
### Rules
@@ -148,6 +148,23 @@ Reports, AI Agents or Inspect to write one about what you're looking at; all of
the current setup can't carry out says so in the list instead of looking enforced.
- A **violations feed** beside the list: every connection a rule decided, refusals and exceptions alike.
+### Managed ad and tracker packs
+Flowlight includes three official, built-in hostname lists: **Advertising**, **Cross-site tracking**, and **Privacy and
+telemetry**. All three are visible in **Rules › Packs** and off by default. Enable a pack for every app or only selected
+apps; enforcement needs the Network Extension, because the sampler can observe connections but cannot refuse them.
+
+Before enabling or broadening a pack, replay it against up to seven days of retained traffic to see which apps,
+destinations, connection counts and bytes would have changed. The activity feed groups repeated blocks instead of
+turning every ad request into a separate alert, while still naming the app, destination, matched list entry and pack.
+From any grouped event, add an exception for that app and destination in that pack. An ordinary allow rule remains the
+universal escape hatch, global **Pause Blocking** pauses packs too, and essential system traffic for accounts, push,
+updates, certificates and iCloud remains allowed.
+
+These are hostname-level network blocks, not page or window cleaners. They cannot remove first-party ads, DOM or layout
+gaps, browser overlays, scripted popups, or native app windows. ECH, QUIC, encrypted DNS, direct-IP connections and
+shared CDNs can hide or blur the hostname and limit coverage. Cosmetic popup filtering needs a later browser extension
+that can see and change page content; the Network Extension cannot do that job.
+
### Agent guardrails
*Which tools may this agent use* is a different question from *which hosts may it reach*, so it gets its own tab in
AI Agents: a switch on every tool the agent declares and every MCP server it reaches, presets (*read-only*, *no
@@ -389,7 +406,7 @@ The [threat model](https://flowlight.xinbetween.com/threat-model/) says what is
started by Claude Code* or *the github MCP server*, not which individual tool call inside a long-running server opened it.
- **Allowlists only block when you ask them to.** Every allowlist warns; turning on *Refuse connections outside the
list*, per agent, makes it refuse as well. That needs the Network Extension engine — the sampler can only watch —
- and it never refuses local traffic, DNS, Apple services or Flowlight's own.
+ and it never refuses local traffic, DNS, essential Apple system services or Flowlight's own.
- **Encrypted payloads stay encrypted by default.** Flowlight reads metadata (hostnames, ports, byte counts). Decryption
happens only with HTTPS inspection turned on, only for apps that use its proxy, and never for apps that pin certificates.
Inspection speaks HTTP/1.1 to both sides.
diff --git a/Shared/AgentPolicy.swift b/Shared/AgentPolicy.swift
index 87a362be..45841e75 100644
--- a/Shared/AgentPolicy.swift
+++ b/Shared/AgentPolicy.swift
@@ -42,10 +42,9 @@ struct AgentPolicy: Codable, Equatable, Sendable {
static func matches(_ pattern: String, host: String, ip: String) -> Bool {
let p = pattern.lowercased()
if p.contains("/") { return cidrContains(p, ip) }
- if isIPAddress(p) { return p == ip.lowercased() }
- let h = host.lowercased()
- guard !h.isEmpty else { return false }
- return h == p || h.hasSuffix("." + p)
+ if isIPAddress(p) { return HostCanonicalizer.address(p) == HostCanonicalizer.address(ip) }
+ guard let domain = HostCanonicalizer.host(p), let h = HostCanonicalizer.host(host) else { return false }
+ return h == domain || h.hasSuffix("." + domain)
}
/// Cleans user input: "https://api.GitHub.com:443/x" → "api.github.com", "*.npmjs.org" → "npmjs.org".
@@ -56,8 +55,11 @@ struct AgentPolicy: Codable, Equatable, Sendable {
if s.hasPrefix(".") { s = String(s.dropFirst()) }
if !s.contains("/") || !isCIDR(s) { s = s.split(separator: "/").first.map(String.init) ?? s }
if !isIPAddress(s), !isCIDR(s), let colon = s.lastIndex(of: ":") { s = String(s[.. Bool {
diff --git a/Shared/BlockRules.swift b/Shared/BlockRules.swift
index 3a69402b..980f6447 100644
--- a/Shared/BlockRules.swift
+++ b/Shared/BlockRules.swift
@@ -29,11 +29,20 @@ enum BlockRules {
/// Never refused whatever an allowlist says. Apple's own services carry the Mac's account, push and software
/// update traffic, and many of them pin certificates, so a refusal there looks like a broken Mac rather than a
/// blocked agent.
+ /// Essential service families rather than every Apple-owned host. Advertising and analytics endpoints are kept
+ /// out deliberately, so Safari and Apple apps can use an enabled pack without risking account, push, update,
+ /// certificate, or iCloud traffic.
static let alwaysAllowedHosts = [
- "apple.com", "icloud.com", "icloud-content.com", "apple-cloudkit.com", "mzstatic.com", "cdn-apple.com",
- "apple-dns.net", "aaplimg.com",
+ "icloud.com", "icloud-content.com", "apple-cloudkit.com", "cdn-apple.com", "apple-dns.net",
]
+ /// Background system processes that carry machine/account infrastructure rather than a user-facing app's web
+ /// traffic. A blanket `com.apple.*` exemption made Safari impossible to protect and contradicted the UI.
+ static let alwaysAllowedBundles = [
+ "com.apple.apsd", "com.apple.cloudd", "com.apple.nsurlsessiond", "com.apple.softwareupdated",
+ "com.apple.trustd", "com.apple.akd", "com.apple.accountsd",
+ ].map { $0.lowercased() }
+
/// Enforcement only stands while the host app is connected to hear about it, plus a few seconds of grace.
///
/// Two reasons. A refusal nobody can see is worse than no refusal at all, and the app is the only place it can
@@ -81,7 +90,7 @@ enum BlockRules {
// Flowlight's own traffic, including anything relayed by its HTTPS inspection proxy.
if facts.bundleID == FlowlightConstants.hostBundleIdentifier
|| facts.bundleID == FlowlightConstants.extensionBundleIdentifier { return true }
- if facts.bundleID.hasPrefix("com.apple.") { return true }
+ if alwaysAllowedBundles.contains(facts.bundleID.lowercased()) { return true }
// Refusing DNS wouldn't stop the agent, it would break name resolution for whatever asked next.
if facts.port == 53 || facts.port == 853 { return true }
return alwaysAllowedHosts.contains { AgentPolicy.matches($0, host: facts.host, ip: facts.ip) }
diff --git a/Shared/Classification/DNSParser.swift b/Shared/Classification/DNSParser.swift
index dcda76e1..79889e49 100644
--- a/Shared/Classification/DNSParser.swift
+++ b/Shared/Classification/DNSParser.swift
@@ -49,8 +49,8 @@ enum DNSParser {
}
i += rdLength
}
- guard !addresses.isEmpty else { return nil }
- return Answer(queriedName: qname.lowercased(), addresses: addresses, ttl: minTTL)
+ guard !addresses.isEmpty, let queriedName = HostCanonicalizer.host(qname) else { return nil }
+ return Answer(queriedName: queriedName, addresses: addresses, ttl: minTTL)
}
private static func readName(_ b: [UInt8], _ i: inout Int) -> String? {
diff --git a/Shared/Classification/HTTPHostParser.swift b/Shared/Classification/HTTPHostParser.swift
index 6ea4d507..70a35a3b 100644
--- a/Shared/Classification/HTTPHostParser.swift
+++ b/Shared/Classification/HTTPHostParser.swift
@@ -14,7 +14,7 @@ enum HTTPHostParser {
} else if let portColon = host.lastIndex(of: ":") {
host = String(host[.. Bool {
- let p = pattern.trimmingCharacters(in: .whitespaces).lowercased()
- let h = host.trimmingCharacters(in: .whitespaces).lowercased()
+ let rawPattern = pattern.trimmingCharacters(in: .whitespaces).lowercased()
// A half-written rule matches nothing rather than everything: an empty host is a rule in progress, and
// answering every request on the Mac from it would be the worst possible surprise.
- guard !p.isEmpty, !h.isEmpty else { return false }
- if p.hasPrefix("*.") || p.hasPrefix(".") {
- let domain = String(p.drop { $0 == "*" || $0 == "." })
- return !domain.isEmpty && (h == domain || h.hasSuffix("." + domain))
+ guard !rawPattern.isEmpty, let h = HostCanonicalizer.host(host) else { return false }
+ if rawPattern.hasPrefix("*.") || rawPattern.hasPrefix(".") {
+ guard let domain = HostCanonicalizer.host(String(rawPattern.drop { $0 == "*" || $0 == "." })) else { return false }
+ return h == domain || h.hasSuffix("." + domain)
}
- return h == p
+ return HostCanonicalizer.host(rawPattern) == h
}
static func method(_ pattern: String, _ method: String) -> Bool {
diff --git a/Shared/HostCanonicalizer.swift b/Shared/HostCanonicalizer.swift
new file mode 100644
index 00000000..1a238a85
--- /dev/null
+++ b/Shared/HostCanonicalizer.swift
@@ -0,0 +1,50 @@
+import Darwin
+import Foundation
+
+/// One spelling for a destination everywhere a blocking decision is made.
+///
+/// DNS names are case-insensitive and may arrive with a root-label dot or in Unicode. A signed pack, the app
+/// previewing it, and the extension enforcing it must not disagree about any of those spellings.
+enum HostCanonicalizer {
+ static func host(_ input: String) -> String? {
+ var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty else { return nil }
+ if value.hasPrefix("["), value.hasSuffix("]") { value = String(value.dropFirst().dropLast()) }
+ if let address = address(value) { return address }
+ if value.hasSuffix(".") { value.removeLast() }
+ guard !value.isEmpty, value.utf8.count <= 253 else { return nil }
+
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = value
+ guard let ascii = components.url?.host?.lowercased(), !ascii.isEmpty, ascii.utf8.count <= 253 else {
+ return nil
+ }
+ let labels = ascii.split(separator: ".", omittingEmptySubsequences: false)
+ guard !labels.isEmpty, labels.allSatisfy(validLabel) else { return nil }
+ return ascii
+ }
+
+ static func address(_ input: String) -> String? {
+ var v4 = in_addr()
+ if inet_pton(AF_INET, input, &v4) == 1 {
+ var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
+ guard inet_ntop(AF_INET, &v4, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil }
+ return String(cString: buffer)
+ }
+ var v6 = in6_addr()
+ if inet_pton(AF_INET6, input, &v6) == 1 {
+ var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
+ guard inet_ntop(AF_INET6, &v6, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil else { return nil }
+ return String(cString: buffer)
+ }
+ return nil
+ }
+
+ private static func validLabel(_ label: Substring) -> Bool {
+ guard !label.isEmpty, label.utf8.count <= 63, label.first != "-", label.last != "-" else { return false }
+ return label.utf8.allSatisfy {
+ ($0 >= 0x61 && $0 <= 0x7A) || ($0 >= 0x30 && $0 <= 0x39) || $0 == 0x2D
+ }
+ }
+}
diff --git a/Shared/IPCProtocol.swift b/Shared/IPCProtocol.swift
index 6c7171cf..88c83fb7 100644
--- a/Shared/IPCProtocol.swift
+++ b/Shared/IPCProtocol.swift
@@ -19,6 +19,15 @@ import Foundation
/// means no rules at all. Like `setEnforcement` this is sent whole on every connection and every change, and
/// like it the extension keeps none of it across a launch: a rule nobody is left to record is not enforced.
func setRules(payload: Data, withReply reply: @escaping (Int) -> Void)
+
+ /// Negotiates the independently-versioned pack protocol before any bytes move.
+ func rulePackHello(appProtocol: Int, withReply reply: @escaping (Data) -> Void)
+ /// Starts one generation. Chunks are accepted only for this header and never affect the active matcher yet.
+ func beginRulePack(payload: Data, withReply reply: @escaping (Data) -> Void)
+ func appendRulePack(generation: String, index: Int, payload: Data, withReply reply: @escaping (Bool) -> Void)
+ /// Validates, compiles, then atomically swaps one complete generation. A failure leaves the previous one active.
+ func commitRulePack(generation: String, withReply reply: @escaping (Data) -> Void)
+ func clearRulePacks(payload: Data, withReply reply: @escaping (Data) -> Void)
}
/// Exported by the host app on the same connection.
@@ -35,4 +44,7 @@ import Foundation
/// Both halves come back: a refusal has to be visible, and so does an exception that let something through,
/// because "why is this getting through?" is the question a rule list exists to answer.
func ruleDecisions(payload: Data, reply: @escaping () -> Void)
+
+ /// Bounded minute rollups from managed packs. True is returned only after the batch is durably committed.
+ func rulePackEvents(payload: Data, reply: @escaping (Bool) -> Void)
}
diff --git a/Shared/RulePack.swift b/Shared/RulePack.swift
new file mode 100644
index 00000000..3d1af9a3
--- /dev/null
+++ b/Shared/RulePack.swift
@@ -0,0 +1,341 @@
+import CryptoKit
+import Foundation
+
+/// A managed, versioned destination list. Pack entries never become ordinary `Rule` rows: their provenance,
+/// activation, matching cost, exceptions, and event volume all have different lifecycles.
+struct RulePackManifest: Codable, Equatable, Sendable, Identifiable {
+ static let formatVersion: UInt16 = 1
+
+ var id: String
+ var version: String
+ var generatedAt: Date
+ var entryCount: Int
+ var contentSHA256: String
+ var minimumAppVersion: String = "0.15.0"
+ var minimumExtensionProtocol: UInt16 = RulePackSnapshot.protocolVersion
+ var titleKey: String
+ var summaryKey: String
+ var caveatKey: String
+ var source: String
+ var license: String
+ var format: UInt16 = formatVersion
+}
+
+struct RulePackEntry: Codable, Equatable, Sendable, Identifiable {
+ enum Match: String, Codable, Sendable { case exact, suffix }
+ enum Category: String, Codable, Sendable { case advertising, tracking, telemetry }
+
+ var id: UInt32
+ var host: String
+ var match: Match = .suffix
+ var category: Category
+}
+
+struct RulePackDefinition: Codable, Equatable, Sendable, Identifiable {
+ var id: String { manifest.id }
+ var manifest: RulePackManifest
+ var entries: [RulePackEntry]
+}
+
+struct RulePackConfiguration: Codable, Equatable, Sendable, Identifiable {
+ enum Scope: String, Codable, Sendable { case allApps, selectedApps }
+
+ var id: String { packID }
+ var packID: String
+ var enabled = false
+ var scope: Scope = .allApps
+ /// Bundle identifiers or process identities. Display names are never persisted as matching identity.
+ var apps: [String] = []
+ /// The exact built-in content the user reviewed. An app update cannot silently broaden an enabled pack.
+ var consentedVersion: String?
+ var consentedContentSHA256: String?
+
+ func applies(to bundleID: String) -> Bool {
+ enabled && (scope == .allApps || apps.contains { $0.caseInsensitiveCompare(bundleID) == .orderedSame })
+ }
+
+ func isConsented(to definition: RulePackDefinition) -> Bool {
+ consentedVersion == definition.manifest.version
+ && consentedContentSHA256 == definition.manifest.contentSHA256
+ }
+}
+
+struct RulePackOverride: Codable, Equatable, Sendable, Identifiable {
+ var id = UUID()
+ var packID: String
+ var app: String
+ var host: String
+ var match: RulePackEntry.Match = .suffix
+ var created = Date()
+
+ func matches(packID candidatePack: String, bundleID: String, host candidateHost: String) -> Bool {
+ guard packID == candidatePack,
+ app.isEmpty || app.caseInsensitiveCompare(bundleID) == .orderedSame,
+ let canonical = HostCanonicalizer.host(candidateHost) else { return false }
+ return match == .exact ? canonical == host : canonical == host || canonical.hasSuffix("." + host)
+ }
+}
+
+struct RulePackSnapshot: Codable, Equatable, Sendable {
+ static let protocolVersion: UInt16 = 1
+
+ var protocolVersion = Self.protocolVersion
+ var generation = UUID()
+ var digest: String
+ var definitions: [RulePackDefinition]
+ var configurations: [RulePackConfiguration]
+ var overrides: [RulePackOverride]
+
+ static func make(definitions: [RulePackDefinition], configurations: [RulePackConfiguration],
+ overrides: [RulePackOverride], generation: UUID = UUID()) -> RulePackSnapshot {
+ let sortedDefinitions = definitions.sorted { $0.id < $1.id }
+ let sortedConfigurations = configurations.sorted { $0.packID < $1.packID }
+ let sortedOverrides = overrides.sorted { $0.id.uuidString < $1.id.uuidString }
+ let digest = contentDigest(definitions: sortedDefinitions, configurations: sortedConfigurations,
+ overrides: sortedOverrides)
+ return RulePackSnapshot(generation: generation, digest: digest, definitions: sortedDefinitions,
+ configurations: sortedConfigurations, overrides: sortedOverrides)
+ }
+
+ var hasValidDigest: Bool {
+ digest == Self.contentDigest(definitions: definitions.sorted { $0.id < $1.id },
+ configurations: configurations.sorted { $0.packID < $1.packID },
+ overrides: overrides.sorted { $0.id.uuidString < $1.id.uuidString })
+ }
+
+ private static func contentDigest(definitions: [RulePackDefinition], configurations: [RulePackConfiguration],
+ overrides: [RulePackOverride]) -> String {
+ let envelope = DigestEnvelope(definitions: definitions, configurations: configurations, overrides: overrides)
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys]
+ let data = (try? encoder.encode(envelope)) ?? Data()
+ return SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private struct DigestEnvelope: Encodable {
+ var definitions: [RulePackDefinition]
+ var configurations: [RulePackConfiguration]
+ var overrides: [RulePackOverride]
+ }
+}
+
+struct RulePackMatch: Equatable, Sendable {
+ var packID: String
+ var packVersion: String
+ var entry: RulePackEntry
+ var matchedHost: String
+}
+
+/// One bounded, already-aggregated report from the extension. `bucket` is the minute it represents; count and
+/// first/last preserve volume and timing without one IPC/database row per ad request.
+struct RulePackHandshake: Codable, Equatable, Sendable {
+ var protocolVersion: UInt16
+ var activeGeneration: UUID?
+ var activeDigest: String
+}
+
+struct RulePackApplyResult: Codable, Equatable, Sendable {
+ enum Status: String, Codable, Sendable { case accepted, committed, cleared, incompatible, invalid, stale }
+ var status: Status
+ var protocolVersion: UInt16 = RulePackSnapshot.protocolVersion
+ var generation: UUID?
+ var activeDigest: String = ""
+ var activePacks = 0
+ var detail = ""
+}
+
+struct RulePackTransferHeader: Codable, Equatable, Sendable {
+ var protocolVersion: UInt16
+ var generation: UUID
+ var digest: String
+ var totalBytes: Int
+ var chunks: Int
+ /// Compare-and-swap base. A transfer that started from an older active generation must not replace a newer one.
+ var expectedActiveGeneration: UUID?
+}
+
+struct RulePackClearRequest: Codable, Equatable, Sendable {
+ var expectedActiveGeneration: UUID?
+}
+
+struct RulePackEvent: Codable, Equatable, Sendable {
+ enum Action: String, Codable, Sendable { case allow, block }
+ var bucket: Int64
+ var packID: String
+ var packVersion: String
+ var entryID: UInt32
+ var category: RulePackEntry.Category
+ var match: RulePackEntry.Match = .suffix
+ var action: Action
+ var count: Int
+ var firstAt: Int64
+ var lastAt: Int64
+ var bundleID: String
+ var appName: String
+ var agentKey: String
+ var agentName: String
+ var host: String
+ var matchedHost: String
+ var port: UInt16
+}
+
+/// One immutable delivery. Its ID makes an uncertain acknowledgement safe to retry without double-counting.
+struct RulePackEventBatch: Codable, Equatable, Sendable {
+ var id = UUID()
+ /// Stable for one extension process. Sequence is strictly increasing because only one batch is ever in flight.
+ var deliverySession = UUID()
+ var sequence: Int64 = 1
+ var events: [RulePackEvent]
+ var droppedHits: Int64 = 0
+ var droppedKeys: Int64 = 0
+}
+
+enum RulePackValidationError: Error, Equatable {
+ case unsupportedFormat(String)
+ case incompatibleProtocol(String)
+ case invalidPackID(String)
+ case duplicatePack(String)
+ case invalidEntry(String, UInt32)
+ case duplicateEntry(String, UInt32)
+ case duplicateHost(String, String)
+ case nonCanonicalHost(String, String)
+ case unsortedEntries(String)
+ case countMismatch(String)
+ case digestMismatch(String)
+}
+
+enum RulePackValidator {
+ static let appVersion = "0.15.0"
+
+ static func validate(_ definitions: [RulePackDefinition], extensionProtocol: UInt16 = RulePackSnapshot.protocolVersion,
+ currentAppVersion: String = appVersion) throws {
+ var packIDs = Set()
+ for definition in definitions {
+ let manifest = definition.manifest
+ guard manifest.format == RulePackManifest.formatVersion else {
+ throw RulePackValidationError.unsupportedFormat(manifest.id)
+ }
+ guard manifest.minimumExtensionProtocol <= extensionProtocol,
+ compareVersions(manifest.minimumAppVersion, currentAppVersion) != .orderedDescending else {
+ throw RulePackValidationError.incompatibleProtocol(manifest.id)
+ }
+ guard !manifest.id.isEmpty,
+ manifest.id.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "-") }) else {
+ throw RulePackValidationError.invalidPackID(manifest.id)
+ }
+ guard packIDs.insert(manifest.id).inserted else { throw RulePackValidationError.duplicatePack(manifest.id) }
+ guard manifest.entryCount == definition.entries.count else {
+ throw RulePackValidationError.countMismatch(manifest.id)
+ }
+
+ var entryIDs = Set(), hosts = Set(), previous = ""
+ for entry in definition.entries {
+ guard entry.id > 0 else { throw RulePackValidationError.invalidEntry(manifest.id, entry.id) }
+ guard entryIDs.insert(entry.id).inserted else {
+ throw RulePackValidationError.duplicateEntry(manifest.id, entry.id)
+ }
+ guard let canonical = HostCanonicalizer.host(entry.host), canonical == entry.host else {
+ throw RulePackValidationError.nonCanonicalHost(manifest.id, entry.host)
+ }
+ let hostKey = "\(entry.host)|\(entry.match.rawValue)"
+ guard hosts.insert(hostKey).inserted else {
+ throw RulePackValidationError.duplicateHost(manifest.id, entry.host)
+ }
+ guard previous.isEmpty || previous < hostKey else {
+ throw RulePackValidationError.unsortedEntries(manifest.id)
+ }
+ previous = hostKey
+ }
+ guard digest(definition.entries) == manifest.contentSHA256 else {
+ throw RulePackValidationError.digestMismatch(manifest.id)
+ }
+ }
+ }
+
+ static func digest(_ entries: [RulePackEntry]) -> String {
+ let canonical = entries.map { "\($0.id)\t\($0.host)\t\($0.match.rawValue)\t\($0.category.rawValue)\n" }.joined()
+ return SHA256.hash(data: Data(canonical.utf8)).map { String(format: "%02x", $0) }.joined()
+ }
+
+ private static func compareVersions(_ lhs: String, _ rhs: String) -> ComparisonResult {
+ let left = lhs.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
+ let right = rhs.split(separator: ".").map { Int($0.prefix(while: \.isNumber)) ?? 0 }
+ for index in 0.. [RulePackMatch] {
+ guard let canonical = HostCanonicalizer.host(host) else { return [] }
+ let labels = canonical.split(separator: ".").reversed()
+ var nodeIndex = 0
+ var decisions: [Decision] = []
+ for label in labels {
+ guard let next = nodes[nodeIndex].children[label] else { break }
+ nodeIndex = next
+ decisions.append(contentsOf: nodes[nodeIndex].suffix)
+ }
+ decisions.append(contentsOf: nodes[nodeIndex].exact.filter { $0.entry.host == canonical })
+ return decisions.compactMap { decision in
+ guard configurations[decision.packID]?.applies(to: bundleID) == true else { return nil }
+ if overrides.contains(where: {
+ $0.matches(packID: decision.packID, bundleID: bundleID, host: canonical)
+ }) { return nil }
+ return RulePackMatch(packID: decision.packID, packVersion: decision.packVersion,
+ entry: decision.entry, matchedHost: canonical)
+ }
+ }
+
+ private func insert(_ entry: RulePackEntry, pack: RulePackDefinition) {
+ var nodeIndex = 0
+ for label in entry.host.split(separator: ".").reversed() {
+ if let existing = nodes[nodeIndex].children[label] {
+ nodeIndex = existing
+ } else {
+ let next = nodes.count
+ nodes.append(Node())
+ nodes[nodeIndex].children[label] = next
+ nodeIndex = next
+ }
+ }
+ let decision = Decision(packID: pack.id, packVersion: pack.manifest.version, entry: entry)
+ if entry.match == .exact { nodes[nodeIndex].exact.append(decision) }
+ else { nodes[nodeIndex].suffix.append(decision) }
+ }
+}
diff --git a/Shared/RulePackSimulation.swift b/Shared/RulePackSimulation.swift
new file mode 100644
index 00000000..b550f817
--- /dev/null
+++ b/Shared/RulePackSimulation.swift
@@ -0,0 +1,67 @@
+import Foundation
+
+/// Replays retained traffic through the full user-rule/managed-pack ordering before a pack is enabled or broadened.
+/// Like `RuleSimulation`, it reports a delta: list membership alone is not a claim that traffic would change.
+enum RulePackSimulation {
+ static func run(candidate: RulePackConfiguration, definitions: [RulePackDefinition],
+ configurations: [RulePackConfiguration], overrides: [RulePackOverride],
+ rules: [Rule], policies: [String: AgentPolicy] = [:], paused: Bool = false,
+ flows: [RuleSimulation.Flow], session: String = "") -> RuleSimulation.Result {
+ let before = matcher(definitions: definitions, configurations: configurations, overrides: overrides)
+ var enabled = candidate
+ enabled.enabled = true
+ let afterConfigurations = configurations.filter { $0.packID != candidate.packID } + [enabled]
+ let after = matcher(definitions: definitions, configurations: afterConfigurations, overrides: overrides)
+
+ var result = RuleSimulation.Result()
+ var merged: [String: RuleSimulation.Change] = [:]
+ for flow in flows {
+ result.flowsConsidered += 1
+ result.connectionsConsidered += flow.connections
+ result.earliest = min(result.earliest ?? flow.when, flow.when)
+ let wasBlocked = blocked(flow, matcher: before, rules: rules, policies: policies,
+ paused: paused, session: session)
+ let willBlock = blocked(flow, matcher: after, rules: rules, policies: policies,
+ paused: paused, session: session)
+ guard wasBlocked != willBlock else { continue }
+
+ let key = "\(flow.bundleID)|\(flow.host)|\(flow.ip)|\(flow.port)|\(willBlock)"
+ if var seen = merged[key] {
+ seen.bytes += flow.bytes
+ seen.connections += flow.connections
+ seen.lastSeen = max(seen.lastSeen, flow.when)
+ merged[key] = seen
+ } else {
+ merged[key] = RuleSimulation.Change(appName: flow.appName, bundleID: flow.bundleID,
+ host: flow.host, ip: flow.ip, port: flow.port,
+ bytes: flow.bytes, connections: flow.connections,
+ lastSeen: flow.when, refused: willBlock)
+ }
+ }
+ result.changes = merged.values.sorted {
+ $0.connections == $1.connections ? $0.bytes > $1.bytes : $0.connections > $1.connections
+ }
+ return result
+ }
+
+ private static func blocked(_ flow: RuleSimulation.Flow, matcher: RulePackMatcher?, rules: [Rule],
+ policies: [String: AgentPolicy], paused: Bool, session: String) -> Bool {
+ guard !paused, !BlockRules.isExempt(flow.facts) else { return false }
+ let user = RuleBook.decide(flow.facts, rules: rules, now: flow.when, session: session)
+ if user.rule != nil { return user.verdict == .block }
+ if matcher?.matches(host: flow.host, bundleID: flow.bundleID).isEmpty == false { return true }
+ guard let policy = policies[flow.agentKey] ?? policies[flow.bundleID] else { return false }
+ return BlockRules.verdict(for: flow.facts, policy: policy) == .block
+ }
+
+ private static func matcher(definitions: [RulePackDefinition], configurations: [RulePackConfiguration],
+ overrides: [RulePackOverride]) -> RulePackMatcher? {
+ let active = configurations.filter(\.enabled)
+ guard !active.isEmpty else { return nil }
+ let ids = Set(active.map(\.packID))
+ let snapshot = RulePackSnapshot.make(definitions: definitions.filter { ids.contains($0.id) },
+ configurations: active,
+ overrides: overrides.filter { ids.contains($0.packID) })
+ return try? RulePackMatcher(snapshot: snapshot)
+ }
+}
diff --git a/Shared/RulePackTransfer.swift b/Shared/RulePackTransfer.swift
new file mode 100644
index 00000000..dc198626
--- /dev/null
+++ b/Shared/RulePackTransfer.swift
@@ -0,0 +1,95 @@
+import Foundation
+
+/// One staged pack upload. Nothing here touches the active matcher: it only validates a bounded sequence of chunks
+/// and returns one complete payload after a compare-and-swap check against the active generation.
+struct RulePackTransferBuffer {
+ enum CommitResult {
+ case payload(Data, RulePackTransferHeader)
+ case rejected(RulePackApplyResult)
+ }
+
+ private(set) var header: RulePackTransferHeader?
+ private(set) var chunks: [Int: Data] = [:]
+ private(set) var receivedBytes = 0
+ let maxBytes: Int
+ let maxChunks: Int
+
+ init(maxBytes: Int = 32 * 1_024 * 1_024, maxChunks: Int = 512) {
+ self.maxBytes = maxBytes
+ self.maxChunks = maxChunks
+ }
+
+ mutating func begin(_ candidate: RulePackTransferHeader,
+ active: RulePackHandshake) -> RulePackApplyResult {
+ guard candidate.protocolVersion == RulePackSnapshot.protocolVersion,
+ candidate.totalBytes > 0, candidate.totalBytes <= maxBytes,
+ candidate.chunks > 0, candidate.chunks <= maxChunks else {
+ return RulePackApplyResult(status: .incompatible, generation: active.activeGeneration,
+ activeDigest: active.activeDigest, detail: "Unsupported pack transfer")
+ }
+ guard candidate.expectedActiveGeneration == active.activeGeneration else {
+ return stale(active)
+ }
+ header = candidate
+ chunks.removeAll(keepingCapacity: true)
+ receivedBytes = 0
+ return RulePackApplyResult(status: .accepted, generation: candidate.generation,
+ activeDigest: active.activeDigest)
+ }
+
+ mutating func append(generation: String, index: Int, payload: Data) -> Bool {
+ guard let header, header.generation.uuidString == generation,
+ index >= 0, index < header.chunks else { return false }
+ if chunks[index] == payload { return true }
+ let oldSize = chunks[index]?.count ?? 0
+ let projected = receivedBytes - oldSize + payload.count
+ guard projected <= header.totalBytes else { return false }
+ chunks[index] = payload
+ receivedBytes = projected
+ return true
+ }
+
+ mutating func commit(generation: String, active: RulePackHandshake) -> CommitResult {
+ guard let header, header.generation.uuidString == generation,
+ chunks.count == header.chunks else {
+ reset()
+ return .rejected(RulePackApplyResult(status: .invalid, generation: active.activeGeneration,
+ activeDigest: active.activeDigest,
+ detail: "Incomplete pack transfer"))
+ }
+ guard header.expectedActiveGeneration == active.activeGeneration else {
+ reset()
+ return .rejected(stale(active))
+ }
+ var data = Data()
+ data.reserveCapacity(receivedBytes)
+ for index in 0.. RulePackApplyResult {
+ RulePackApplyResult(status: .stale, generation: active.activeGeneration,
+ activeDigest: active.activeDigest,
+ detail: "The active pack generation changed.")
+ }
+}
diff --git a/docs/docs/index.html b/docs/docs/index.html
index fa35fbbe..c311e756 100644
--- a/docs/docs/index.html
+++ b/docs/docs/index.html
@@ -48,7 +48,7 @@
-
+
Skip to content
@@ -458,6 +458,34 @@
Rules
of them says the rule is safe. The screen also names the oldest traffic it could see: a rule tested against two hours of
history has been tested against two hours of history.
+
Managed ad and tracker packs
+
Rules › Packs contains three official lists built into Flowlight: Advertising covers known
+ advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and
+ cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and
+ audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.
+ Nothing changes on an update until you choose a pack and enable it.
+
Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs
+ the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential
+ system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates,
+ certificate checks and iCloud — even when a pack applies globally.
+
Preview before enforcing.Test against recent traffic replays the proposed pack and scope against up
+ to seven days of retained traffic. It reports only decisions that would actually change after ordinary rules, the global pause,
+ existing packs and agent policies are taken into account: the affected apps and destinations, connection counts, and bytes.
+ "Would have changed nothing" is kept distinct from having no retained traffic to test.
+
Activity stays aggregated and explainable. Repeated blocks are grouped so a page full of ads does not become
+ a page full of alerts. Each row still names the app and destination, the list entry that matched, the pack and its version, and
+ the number of connections. Allow for this app creates a pack-specific app-and-destination exception; it does not
+ silently disable that destination for other apps or remove it from another pack.
+
The ordinary controls still win. An ordinary allow rule is the universal escape hatch and can allow traffic a
+ pack would otherwise refuse. The global Pause Blocking control pauses managed packs along with the rest of
+ connection blocking. Pack exceptions are narrower conveniences; ordinary rules remain the place for a deliberate policy that
+ should apply beyond one pack.
+
This is hostname-level network blocking, not content filtering. A Network Extension can refuse a connection,
+ but it cannot edit what a browser or app draws. Packs therefore cannot remove first-party ads, DOM or layout gaps left behind by
+ blocked content, browser overlays, scripted popups, or native app windows. ECH, QUIC, encrypted DNS, direct-IP connections and
+ shared CDNs can hide a hostname or make one hostname serve unrelated content, limiting what a destination list can cover safely.
+ Cosmetic popup filtering needs a later browser extension that can inspect and change the page itself.
+
Allowlists
Select an agent in AI Agents and use Allowlist in its detail to say where it may connect. Enter domains
(subdomains included), IP addresses or CIDR ranges, or pick a preset: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, Go modules or
@@ -472,7 +500,7 @@
Refusing connections
to the list in one click. The agent's next attempt goes through.
It needs the Network Extension. Refusing a connection means sitting in its path, which the nettop sampler
cannot do — Capture will say so rather than offer a switch that does nothing.
-
Some things are never refused, whatever a list says: your local network, DNS, Apple's own services, and Flowlight's own
+
Some things are never refused, whatever a list says: your local network, DNS, essential Apple system services, and Flowlight's own
traffic. A blocked Mac is a worse outcome than a blocked agent, so where there is doubt the connection is allowed and recorded.
Enforcement also lapses a few seconds after Flowlight stops running — a refusal nobody can see is worse than none, and the way
to undo one lives in the app.
Cursor ⇥ raw.githubusercontent.com · refused 4 times · weekdays 18:00–09:00
+
+ ⛔ Opt-in
+
Managed ad and tracker packs
+
Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by
+ default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
+ see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal
+ escape hatch, global Pause Blocking applies, and essential system traffic stays allowed.
+
They block by hostname, so they cannot remove first-party ads, DOM or layout gaps, browser overlays, scripted popups, or
+ native app windows. ECH, QUIC, encrypted DNS, direct IPs and shared CDNs can limit coverage. Cosmetic popup filtering needs
+ a later browser extension that can change the page.
By default it watches and alerts, including when an agent leaves its allowlist. It can also refuse those connections,
but only for an agent you have turned that on for, and only with the Network Extension engine installed — the sampler can
- only watch. Local traffic, DNS, Apple services and Flowlight's own connections are never refused.
+ only watch. Local traffic, DNS, essential Apple system services and Flowlight's own connections are never refused.
Can it read my HTTPS traffic?
Not by default. It reads the hostname a client asks for when it opens a connection (TLS SNI) and the DNS answers
that map names to addresses, and contents stay encrypted. If you want the full picture, HTTPS inspection is a
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index a97dae01..93d664d8 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -512,6 +512,40 @@ open build/Build/Products/Release/Flowlight.app
of them says the rule is safe. The screen also names the oldest traffic it could see: a rule tested against two hours of
history has been tested against two hours of history.
+ Managed ad and tracker packs
+
+ Rules › Packs contains three official lists built into Flowlight: Advertising covers known
+ advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and
+ cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and
+ audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.
+ Nothing changes on an update until you choose a pack and enable it.
+
+ Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs
+ the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential
+ system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates,
+ certificate checks and iCloud — even when a pack applies globally.
+
+ Preview before enforcing. Test against recent traffic replays the proposed pack and scope against up
+ to seven days of retained traffic. It reports only decisions that would actually change after ordinary rules, the global pause,
+ existing packs and agent policies are taken into account: the affected apps and destinations, connection counts, and bytes.
+ "Would have changed nothing" is kept distinct from having no retained traffic to test.
+
+ Activity stays aggregated and explainable. Repeated blocks are grouped so a page full of ads does not become
+ a page full of alerts. Each row still names the app and destination, the list entry that matched, the pack and its version, and
+ the number of connections. Allow for this app creates a pack-specific app-and-destination exception; it does not
+ silently disable that destination for other apps or remove it from another pack.
+
+ The ordinary controls still win. An ordinary allow rule is the universal escape hatch and can allow traffic a
+ pack would otherwise refuse. The global Pause Blocking control pauses managed packs along with the rest of
+ connection blocking. Pack exceptions are narrower conveniences; ordinary rules remain the place for a deliberate policy that
+ should apply beyond one pack.
+
+ This is hostname-level network blocking, not content filtering. A Network Extension can refuse a connection,
+ but it cannot edit what a browser or app draws. Packs therefore cannot remove first-party ads, DOM or layout gaps left behind by
+ blocked content, browser overlays, scripted popups, or native app windows. ECH, QUIC, encrypted DNS, direct-IP connections and
+ shared CDNs can hide a hostname or make one hostname serve unrelated content, limiting what a destination list can cover safely.
+ Cosmetic popup filtering needs a later browser extension that can inspect and change the page itself.
+
Allowlists
Select an agent in AI Agents and use Allowlist in its detail to say where it may connect. Enter domains
@@ -531,7 +565,7 @@ open build/Build/Products/Release/Flowlight.app
It needs the Network Extension. Refusing a connection means sitting in its path, which the nettop sampler
cannot do — Capture will say so rather than offer a switch that does nothing.
- Some things are never refused, whatever a list says: your local network, DNS, Apple's own services, and Flowlight's own
+ Some things are never refused, whatever a list says: your local network, DNS, essential Apple system services, and Flowlight's own
traffic. A blocked Mac is a worse outcome than a blocked agent, so where there is doubt the connection is allowed and recorded.
Enforcement also lapses a few seconds after Flowlight stops running — a refusal nobody can see is worse than none, and the way
to undo one lives in the app.
@@ -1000,6 +1034,20 @@ Optional. What did the agent actually send, and which tool call made it? Decrypt
Cursor ⇥ raw.githubusercontent.com · refused 4 times · weekdays 18:00–09:00
+ ⛔ Opt-in
+ Managed ad and tracker packs
+
+ Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by
+ default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
+ see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal
+ escape hatch, global Pause Blocking applies, and essential system traffic stays allowed.
+
+ They block by hostname, so they cannot remove first-party ads, DOM or layout gaps, browser overlays, scripted popups, or
+ native app windows. ECH, QUIC, encrypted DNS, direct IPs and shared CDNs can limit coverage. Cosmetic popup filtering needs
+ a later browser extension that can change the page.
+
+ Safari · Advertising matched doubleclick.net · 18 connections grouped
+
⛔ Refuses
Guardrails on an agent's tools
@@ -1246,7 +1294,7 @@ Questions
By default it watches and alerts, including when an agent leaves its allowlist. It can also refuse those connections,
but only for an agent you have turned that on for, and only with the Network Extension engine installed — the sampler can
- only watch. Local traffic, DNS, Apple services and Flowlight's own connections are never refused.
+ only watch. Local traffic, DNS, essential Apple system services and Flowlight's own connections are never refused.
Can it read my HTTPS traffic?
diff --git a/docs/sitemap.xml b/docs/sitemap.xml
index b0980d3b..5b51fb07 100644
--- a/docs/sitemap.xml
+++ b/docs/sitemap.xml
@@ -1,8 +1,8 @@
https://flowlight.xinbetween.com/about/2026-09-25
- https://flowlight.xinbetween.com/docs/2026-10-07
- https://flowlight.xinbetween.com/2026-10-07
+ https://flowlight.xinbetween.com/docs/2026-10-17
+ https://flowlight.xinbetween.com/2026-10-17https://flowlight.xinbetween.com/plugins/2026-10-13https://flowlight.xinbetween.com/privacy/2026-10-07https://flowlight.xinbetween.com/releases/2026-10-17
diff --git a/site/pages/docs.html b/site/pages/docs.html
index a7d2a7c6..9d9dc93d 100644
--- a/site/pages/docs.html
+++ b/site/pages/docs.html
@@ -390,6 +390,34 @@
Rules
of them says the rule is safe. The screen also names the oldest traffic it could see: a rule tested against two hours of
history has been tested against two hours of history.
+
Managed ad and tracker packs
+
Rules › Packs contains three official lists built into Flowlight: Advertising covers known
+ advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and
+ cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and
+ audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.
+ Nothing changes on an update until you choose a pack and enable it.
+
Each pack has its own scope. Apply it globally to every app, or only to selected applications. Blocking needs
+ the Network Extension: the nettop sampler can record traffic, but it cannot sit in a connection's path and refuse it. Essential
+ system traffic remains allowed — including the services that carry Apple accounts, push notifications, software updates,
+ certificate checks and iCloud — even when a pack applies globally.
+
Preview before enforcing.Test against recent traffic replays the proposed pack and scope against up
+ to seven days of retained traffic. It reports only decisions that would actually change after ordinary rules, the global pause,
+ existing packs and agent policies are taken into account: the affected apps and destinations, connection counts, and bytes.
+ "Would have changed nothing" is kept distinct from having no retained traffic to test.
+
Activity stays aggregated and explainable. Repeated blocks are grouped so a page full of ads does not become
+ a page full of alerts. Each row still names the app and destination, the list entry that matched, the pack and its version, and
+ the number of connections. Allow for this app creates a pack-specific app-and-destination exception; it does not
+ silently disable that destination for other apps or remove it from another pack.
+
The ordinary controls still win. An ordinary allow rule is the universal escape hatch and can allow traffic a
+ pack would otherwise refuse. The global Pause Blocking control pauses managed packs along with the rest of
+ connection blocking. Pack exceptions are narrower conveniences; ordinary rules remain the place for a deliberate policy that
+ should apply beyond one pack.
+
This is hostname-level network blocking, not content filtering. A Network Extension can refuse a connection,
+ but it cannot edit what a browser or app draws. Packs therefore cannot remove first-party ads, DOM or layout gaps left behind by
+ blocked content, browser overlays, scripted popups, or native app windows. ECH, QUIC, encrypted DNS, direct-IP connections and
+ shared CDNs can hide a hostname or make one hostname serve unrelated content, limiting what a destination list can cover safely.
+ Cosmetic popup filtering needs a later browser extension that can inspect and change the page itself.
+
Allowlists
Select an agent in AI Agents and use Allowlist in its detail to say where it may connect. Enter domains
(subdomains included), IP addresses or CIDR ranges, or pick a preset: GitHub, npm, PyPI, Homebrew, Docker Hub, Rust crates, Go modules or
@@ -404,7 +432,7 @@
Refusing connections
to the list in one click. The agent's next attempt goes through.
It needs the Network Extension. Refusing a connection means sitting in its path, which the nettop sampler
cannot do — Capture will say so rather than offer a switch that does nothing.
-
Some things are never refused, whatever a list says: your local network, DNS, Apple's own services, and Flowlight's own
+
Some things are never refused, whatever a list says: your local network, DNS, essential Apple system services, and Flowlight's own
traffic. A blocked Mac is a worse outcome than a blocked agent, so where there is doubt the connection is allowed and recorded.
Enforcement also lapses a few seconds after Flowlight stops running — a refusal nobody can see is worse than none, and the way
to undo one lives in the app.
Cursor ⇥ raw.githubusercontent.com · refused 4 times · weekdays 18:00–09:00
+
+ ⛔ Opt-in
+
Managed ad and tracker packs
+
Three official packs — Advertising, Cross-site tracking, and Privacy and telemetry — are built in, visible and off by
+ default. With the Network Extension, apply one to every app or only selected apps; preview it against retained traffic,
+ see grouped, explainable activity, and add pack-specific exceptions for an app. Ordinary allow rules remain the universal
+ escape hatch, global Pause Blocking applies, and essential system traffic stays allowed.
+
They block by hostname, so they cannot remove first-party ads, DOM or layout gaps, browser overlays, scripted popups, or
+ native app windows. ECH, QUIC, encrypted DNS, direct IPs and shared CDNs can limit coverage. Cosmetic popup filtering needs
+ a later browser extension that can change the page.
By default it watches and alerts, including when an agent leaves its allowlist. It can also refuse those connections,
but only for an agent you have turned that on for, and only with the Network Extension engine installed — the sampler can
- only watch. Local traffic, DNS, Apple services and Flowlight's own connections are never refused.
+ only watch. Local traffic, DNS, essential Apple system services and Flowlight's own connections are never refused.
Can it read my HTTPS traffic?
Not by default. It reads the hostname a client asks for when it opens a connection (TLS SNI) and the DNS answers
that map names to addresses, and contents stay encrypted. If you want the full picture, HTTPS inspection is a