diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 93d664d8..56e119ba 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -62,7 +62,7 @@ Documentation
Using Flowlight
- Everything from the first launch to tuning the agent rules. Flowlight 0.14.5, macOS 15 or later.
+ Everything from the first launch to tuning the agent rules. Flowlight 0.15.0, macOS 15 or later.
On this page
@@ -887,7 +887,7 @@ Understand your AI agents.
brew install --cask xinbetween/tap/flowlightCopy
- v0.14.5macOS 15+UniversalGPL-3.0No telemetry
+ v0.15.0macOS 15+UniversalGPL-3.0No telemetry
connectionslive
@@ -1617,9 +1617,28 @@ Releases
Downloads, checksums and full notes for each version are on GitHub Releases.
- 0.14.5
+ 0.15.0
October 17, 2026Latest
+ Block known ad and tracker destinations across apps. Three official managed packs cover
+ advertising, cross-site tracking, and privacy/telemetry hosts. They ship disabled, require explicit review,
+ and can apply to every app or only selected apps.
+
+ Keep blocking explainable and reversible. Preview seven days of retained traffic before
+ enabling a pack, review its built-in destinations and provenance, inspect aggregated activity, add scoped
+ per-app exceptions, or use an ordinary allow rule as the universal escape hatch.
+
+ Fail open with atomic, version-bound enforcement. Flowlight validates and transfers one
+ complete pack generation to the Network Extension, binds consent to the exact reviewed content, preserves
+ ordinary-rule and essential-system precedence, and never treats a partial or stale update as active.
+
+ Set honest network-only expectations. Hostname blocking cannot remove first-party ads,
+ page gaps, browser overlays, scripted popups or native app windows; those need the later browser extension.
+ ECH, QUIC, encrypted DNS, direct IPs, shared CDNs and unavailable hostnames can also limit coverage.
+
+ 0.14.5
+October 17, 2026
+
Review plugin actions in Flowlight's existing editors. Plugin findings can now suggest guardrails,
network rules, mock responses, request rewrites and response transforms as typed, inert actions. Inspect opens the
matching editor with bounded defaults, and nothing changes until the user reviews and saves it.
diff --git a/docs/llms.txt b/docs/llms.txt
index 86ab145d..bc4480cd 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -1,6 +1,6 @@
# Flowlight
-> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.14.5.
+> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.0.
- [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg)
- [Source code](https://github.com/xinbetween/flowlight)
diff --git a/docs/plugins/index.html b/docs/plugins/index.html
index f79a6096..3267f5d1 100644
--- a/docs/plugins/index.html
+++ b/docs/plugins/index.html
@@ -211,7 +211,7 @@
Block known ad and tracker destinations across apps. Three official managed packs cover
+ advertising, cross-site tracking, and privacy/telemetry hosts. They ship disabled, require explicit review,
+ and can apply to every app or only selected apps.
+
Keep blocking explainable and reversible. Preview seven days of retained traffic before
+ enabling a pack, review its built-in destinations and provenance, inspect aggregated activity, add scoped
+ per-app exceptions, or use an ordinary allow rule as the universal escape hatch.
+
Fail open with atomic, version-bound enforcement. Flowlight validates and transfers one
+ complete pack generation to the Network Extension, binds consent to the exact reviewed content, preserves
+ ordinary-rule and essential-system precedence, and never treats a partial or stale update as active.
+
Set honest network-only expectations. Hostname blocking cannot remove first-party ads,
+ page gaps, browser overlays, scripted popups or native app windows; those need the later browser extension.
+ ECH, QUIC, encrypted DNS, direct IPs, shared CDNs and unavailable hostnames can also limit coverage.
+
+
+
+
+
0.14.5
Review plugin actions in Flowlight's existing editors. Plugin findings can now suggest guardrails,
network rules, mock responses, request rewrites and response transforms as typed, inert actions. Inspect opens the
@@ -1215,7 +1233,7 @@
Block known ad and tracker destinations across apps. Three official managed packs cover
+ advertising, cross-site tracking, and privacy/telemetry hosts. They ship disabled, require explicit review,
+ and can apply to every app or only selected apps.
+
Keep blocking explainable and reversible. Preview seven days of retained traffic before
+ enabling a pack, review its built-in destinations and provenance, inspect aggregated activity, add scoped
+ per-app exceptions, or use an ordinary allow rule as the universal escape hatch.
+
Fail open with atomic, version-bound enforcement. Flowlight validates and transfers one
+ complete pack generation to the Network Extension, binds consent to the exact reviewed content, preserves
+ ordinary-rule and essential-system precedence, and never treats a partial or stale update as active.
+
Set honest network-only expectations. Hostname blocking cannot remove first-party ads,
+ page gaps, browser overlays, scripted popups or native app windows; those need the later browser extension.
+ ECH, QUIC, encrypted DNS, direct IPs, shared CDNs and unavailable hostnames can also limit coverage.
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 56e119ba..5cc393a3 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -62,7 +62,7 @@ Documentation
Using Flowlight
- Everything from the first launch to tuning the agent rules. Flowlight 0.15.0, macOS 15 or later.
+ Everything from the first launch to tuning the agent rules. Flowlight 0.15.1, macOS 15 or later.
On this page
@@ -887,7 +887,7 @@ Understand your AI agents.
brew install --cask xinbetween/tap/flowlightCopy
- v0.15.0macOS 15+UniversalGPL-3.0No telemetry
+ v0.15.1macOS 15+UniversalGPL-3.0No telemetry
connectionslive
@@ -1617,8 +1617,20 @@ Releases
Downloads, checksums and full notes for each version are on GitHub Releases.
+ 0.15.1
+October 18, 2026Latest
+
+ Keep managed-pack changes safely in review. Opening or cancelling an off pack no longer
+ activates it, and configuration changes persist only after an explicit Save succeeds.
+
+ Add selected apps without closing the editor. Pressing Return in the app field now adds
+ the typed app or process to the draft instead of triggering the sheet's Save action.
+
+ Show the configuration that actually committed. Pack cards now observe managed-pack state
+ directly, so scope, status, exceptions and activity refresh as soon as an asynchronous save completes.
+
0.15.0
-October 17, 2026Latest
+October 17, 2026
Block known ad and tracker destinations across apps. Three official managed packs cover
advertising, cross-site tracking, and privacy/telemetry hosts. They ship disabled, require explicit review,
diff --git a/docs/llms.txt b/docs/llms.txt
index bc4480cd..b6693510 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -1,6 +1,6 @@
# Flowlight
-> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.0.
+> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.1.
- [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg)
- [Source code](https://github.com/xinbetween/flowlight)
diff --git a/docs/plugins/index.html b/docs/plugins/index.html
index 3267f5d1..0ba7b180 100644
--- a/docs/plugins/index.html
+++ b/docs/plugins/index.html
@@ -211,7 +211,7 @@
Keep managed-pack changes safely in review. Opening or cancelling an off pack no longer
+ activates it, and configuration changes persist only after an explicit Save succeeds.
+
Add selected apps without closing the editor. Pressing Return in the app field now adds
+ the typed app or process to the draft instead of triggering the sheet's Save action.
+
Show the configuration that actually committed. Pack cards now observe managed-pack state
+ directly, so scope, status, exceptions and activity refresh as soon as an asynchronous save completes.
+
+
+
+
+
0.15.0
Block known ad and tracker destinations across apps. Three official managed packs cover
advertising, cross-site tracking, and privacy/telemetry hosts. They ship disabled, require explicit review,
@@ -1233,7 +1245,7 @@
Keep managed-pack changes safely in review. Opening or cancelling an off pack no longer
+ activates it, and configuration changes persist only after an explicit Save succeeds.
+
Add selected apps without closing the editor. Pressing Return in the app field now adds
+ the typed app or process to the draft instead of triggering the sheet's Save action.
+
Show the configuration that actually committed. Pack cards now observe managed-pack state
+ directly, so scope, status, exceptions and activity refresh as soon as an asynchronous save completes.
+
+
+
+
+
0.15.0
Block known ad and tracker destinations across apps. Three official managed packs cover
advertising, cross-site tracking, and privacy/telemetry hosts. They ship disabled, require explicit review,
From 876fe93e0557f2630d1ebf0d16cae1a72a7b7d0c Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Sun, 18 Oct 2026 08:28:28 -0700
Subject: [PATCH 3/5] Harden managed pack editor saves
Co-Authored-By: Claude Code
---
Flowlight/Analysis/InstalledApps.swift | 29 ++++--
Flowlight/Analysis/RulePackStore.swift | 12 ++-
Flowlight/UI/RulePackEditorDraft.swift | 21 ++--
Flowlight/UI/RulePacksView.swift | 138 ++++++++++++++++++++-----
FlowlightTests/RulePackTests.swift | 38 ++++++-
5 files changed, 187 insertions(+), 51 deletions(-)
diff --git a/Flowlight/Analysis/InstalledApps.swift b/Flowlight/Analysis/InstalledApps.swift
index c4ed9c92..4fc96db3 100644
--- a/Flowlight/Analysis/InstalledApps.swift
+++ b/Flowlight/Analysis/InstalledApps.swift
@@ -17,8 +17,13 @@ enum InstalledApps {
var bluetoothPurpose: String?
}
+ struct ScanResult: Sendable {
+ var apps: [App]
+ var complete: Bool
+ }
+
private static let lock = NSLock()
- private static var cached: [App]?
+ private static var cached: ScanResult?
/// Where applications live. The system folder is included because Safari and Mail are perfectly reasonable
/// things to focus on, and they aren't in /Applications.
@@ -29,15 +34,22 @@ enum InstalledApps {
/// Scanned once and kept. A Mac doesn't gain applications while a popover is open, and re-reading several
/// hundred Info.plists on every keystroke would be felt.
- static func all() -> [App] {
+ static func all() -> [App] { scan().apps }
+
+ static func scan() -> ScanResult {
lock.lock()
if let cached { lock.unlock(); return cached }
lock.unlock()
var found: [String: App] = [:]
+ var complete = true
let manager = FileManager.default
for directory in searchPaths {
- guard let entries = try? manager.contentsOfDirectory(atPath: directory) else { continue }
+ guard manager.fileExists(atPath: directory) else { continue }
+ guard let entries = try? manager.contentsOfDirectory(atPath: directory) else {
+ complete = false
+ continue
+ }
for entry in entries where entry.hasSuffix(".app") {
let path = directory + "/" + entry
guard let bundle = Bundle(path: path), let id = bundle.bundleIdentifier, !id.isEmpty else { continue }
@@ -49,9 +61,14 @@ enum InstalledApps {
found[id] = App(name: name, bundleID: id, path: path, bluetoothPurpose: purpose)
}
}
- let sorted = found.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
- lock.lock(); cached = sorted; lock.unlock()
- return sorted
+ let result = ScanResult(
+ apps: found.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending },
+ complete: complete && !found.isEmpty
+ )
+ if result.complete {
+ lock.lock(); cached = result; lock.unlock()
+ }
+ return result
}
/// The applications built to use Bluetooth, as they say so themselves in their Info.plist.
diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift
index 718a29c7..acede341 100644
--- a/Flowlight/Analysis/RulePackStore.swift
+++ b/Flowlight/Analysis/RulePackStore.swift
@@ -31,6 +31,10 @@ final class RulePackStore: ObservableObject {
var enabledCount: Int { configurations.filter(\.enabled).count }
var hasEnabledPacks: Bool { enabledCount > 0 }
+ var failureMessage: String? {
+ if case .failed(let message) = status { return message }
+ return nil
+ }
func configuration(for packID: String) -> RulePackConfiguration {
configurations.first { $0.packID == packID } ?? RulePackConfiguration(packID: packID)
@@ -185,7 +189,13 @@ final class RulePackStore: ObservableObject {
return
}
db.queue.async { [weak self, weak db] in
- guard let db else { return }
+ guard let db else {
+ Task { @MainActor in
+ self?.status = .failed("Rule pack storage is unavailable.")
+ onFailure?()
+ }
+ return
+ }
do {
try write(db)
Task { @MainActor in commit() }
diff --git a/Flowlight/UI/RulePackEditorDraft.swift b/Flowlight/UI/RulePackEditorDraft.swift
index 279ccca3..92873225 100644
--- a/Flowlight/UI/RulePackEditorDraft.swift
+++ b/Flowlight/UI/RulePackEditorDraft.swift
@@ -9,6 +9,7 @@ struct RulePackEditorDraft {
}
enum AppSubmission: Equatable {
+ case loading
case ignored
case added(String)
}
@@ -21,7 +22,6 @@ struct RulePackEditorDraft {
let originalConfiguration: RulePackConfiguration
var configuration: RulePackConfiguration
var typedApp = ""
- private(set) var saveAction: SaveAction?
init(saved: RulePackConfiguration, intent: Intent) {
originalConfiguration = saved
@@ -33,11 +33,12 @@ struct RulePackEditorDraft {
configuration.scope != .selectedApps || !configuration.apps.isEmpty
}
- mutating func submitApp(installed: [InstalledApps.App]) -> AppSubmission {
- addApp(typedApp, installed: installed)
+ mutating func submitApp(installed: [InstalledApps.App], appsLoaded: Bool = true) -> AppSubmission {
+ addApp(typedApp, installed: installed, appsLoaded: appsLoaded)
}
- mutating func addApp(_ value: String, installed: [InstalledApps.App]) -> AppSubmission {
+ mutating func addApp(_ value: String, installed: [InstalledApps.App], appsLoaded: Bool = true) -> AppSubmission {
+ guard appsLoaded else { return .loading }
guard let target = InstalledApps.target(forTyped: value, apps: installed) else { return .ignored }
if !configuration.apps.contains(where: { $0.caseInsensitiveCompare(target.value) == .orderedSame }) {
configuration.apps.append(target.value)
@@ -47,17 +48,13 @@ struct RulePackEditorDraft {
return .added(target.value)
}
- mutating func requestSave() -> SaveAction? {
- guard canSave else { saveAction = nil; return nil }
+ func requestSave() -> SaveAction? {
+ guard canSave else { return nil }
if originalConfiguration.enabled,
originalConfiguration.scope == .selectedApps,
configuration.scope == .allApps {
- saveAction = .confirmBroadening
- } else {
- saveAction = .commit(configuration)
+ return .confirmBroadening
}
- return saveAction
+ return .commit(configuration)
}
-
- mutating func clearSaveAction() { saveAction = nil }
}
diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift
index 8a9d60ee..4d4a0cd9 100644
--- a/Flowlight/UI/RulePacksView.swift
+++ b/Flowlight/UI/RulePacksView.swift
@@ -10,15 +10,24 @@ struct RulePacksView: View {
private struct RulePackEditorRequest: Identifiable {
let pack: RulePackDefinition
- let saved: RulePackConfiguration
let intent: RulePackEditorDraft.Intent
var id: String { pack.id }
}
+struct RulePackSaveTracker {
+ private(set) var packIDs: Set = []
+
+ func contains(_ packID: String) -> Bool { packIDs.contains(packID) }
+ mutating func begin(_ packID: String) { packIDs.insert(packID) }
+ mutating func finish(_ packID: String) { packIDs.remove(packID) }
+}
+
private struct RulePacksContent: View {
@EnvironmentObject var monitor: TrafficMonitor
@ObservedObject var packs: RulePackStore
@State private var editor: RulePackEditorRequest?
+ @State private var saves = RulePackSaveTracker()
+ @State private var saveErrors: [String: String] = [:]
var body: some View {
ScrollView {
@@ -37,8 +46,10 @@ private struct RulePacksContent: View {
.padding(Measure.gutter)
}
.sheet(item: $editor) { request in
- RulePackEditor(pack: request.pack, saved: request.saved, intent: request.intent, packs: packs)
+ RulePackEditor(pack: request.pack, saved: packs.configuration(for: request.pack.id),
+ intent: request.intent, packs: packs, rules: monitor.rules)
}
+ .onChange(of: packs.version) { _, _ in saveErrors.removeAll() }
}
private var explanation: some View {
@@ -54,6 +65,8 @@ private struct RulePacksContent: View {
let configuration = packs.configuration(for: pack.id)
let events = packs.events(for: pack.id)
let blocks = events.reduce(0) { $0 + $1.count }
+ let exceptions = packs.overrides(for: pack.id)
+ let isSaving = saves.contains(pack.id)
return VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: symbol(pack)).font(.title3).foregroundStyle(configuration.enabled ? FL.critical : .secondary)
@@ -66,26 +79,35 @@ private struct RulePacksContent: View {
Spacer(minLength: 8)
Toggle("", isOn: Binding(get: { configuration.enabled }, set: { enabled in
if enabled {
- editor = RulePackEditorRequest(pack: pack, saved: configuration, intent: .activate)
+ editor = RulePackEditorRequest(pack: pack, intent: .activate)
} else {
var changed = configuration
changed.enabled = false
- packs.save(changed)
+ saves.begin(pack.id)
+ saveErrors[pack.id] = nil
+ packs.save(changed) { success in
+ saves.finish(pack.id)
+ if !success { saveErrors[pack.id] = packs.failureMessage ?? L("Failed: %@", L("Save")) }
+ }
}
- })).toggleStyle(.switch).controlSize(.small).labelsHidden()
+ })).toggleStyle(.switch).controlSize(.small).labelsHidden().disabled(isSaving)
}
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 !packs.overrides(for: pack.id).isEmpty {
- pill(L("%lld exceptions", packs.overrides(for: pack.id).count), tint: FL.good)
+ if !exceptions.isEmpty {
+ pill(L("%lld exceptions", exceptions.count), tint: FL.good)
}
Spacer()
Button(L("Review…")) {
- editor = RulePackEditorRequest(pack: pack, saved: configuration, intent: .review)
- }
+ editor = RulePackEditorRequest(pack: pack, intent: .review)
+ }.disabled(isSaving)
+ }
+ if let saveError = saveErrors[pack.id] {
+ Label(saveError, systemImage: "exclamationmark.triangle")
+ .font(.caption).foregroundStyle(FL.critical)
}
Text(L(pack.manifest.caveatKey)).font(.caption).foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -144,17 +166,23 @@ private struct RulePackEditor: View {
@EnvironmentObject var monitor: TrafficMonitor
@Environment(\.dismiss) private var dismiss
@ObservedObject var packs: RulePackStore
+ @ObservedObject var rules: RuleStore
@State private var draft: RulePackEditorDraft
@State private var installed: [InstalledApps.App] = []
+ @State private var appsLoaded = false
+ @State private var appsScanned = false
@State private var preview: RuleSimulation.Result?
@State private var previewing = false
+ @State private var previewError: String?
+ @State private var saveError: String?
@State private var confirmBroadening = false
@State private var saving = false
init(pack: RulePackDefinition, saved: RulePackConfiguration,
- intent: RulePackEditorDraft.Intent, packs: RulePackStore) {
+ intent: RulePackEditorDraft.Intent, packs: RulePackStore, rules: RuleStore) {
self.pack = pack
self.packs = packs
+ self.rules = rules
_draft = State(initialValue: RulePackEditorDraft(saved: saved, intent: intent))
}
@@ -174,21 +202,30 @@ private struct RulePackEditor: View {
exceptions
}.padding(Measure.gutter)
}
+ .disabled(saving)
+ if let saveError {
+ Label(saveError, systemImage: "exclamationmark.triangle")
+ .font(.caption).foregroundStyle(FL.critical)
+ .padding(.horizontal, Measure.gutter).padding(.bottom, 8)
+ }
Divider()
HStack {
Button(L("Test against recent traffic")) { Task { await runPreview() } }
+ .disabled(previewing || saving)
if previewing { ProgressView().controlSize(.small) }
Spacer()
- Button(L("Cancel")) { dismiss() }
+ Button(L("Cancel")) { dismiss() }.disabled(saving)
Button(L("Save")) { save() }
.disabled(!draft.canSave || saving)
}.padding(Measure.gutter)
}
.frame(width: Measure.prose)
.frame(minHeight: 620)
- .onAppear {
- Task { installed = await Task.detached(priority: .userInitiated) { InstalledApps.all() }.value }
- }
+ .interactiveDismissDisabled(saving)
+ .onChange(of: draft.configuration) { _, _ in invalidatePreview() }
+ .onChange(of: packs.version) { _, _ in invalidatePreview() }
+ .onChange(of: rules.version) { _, _ in invalidatePreview() }
+ .onAppear { loadApps() }
.confirmationDialog(L("Apply this pack to every app?"), isPresented: $confirmBroadening) {
Button(L("Apply to every app"), role: .destructive) { commit(draft.configuration) }
Button(L("Cancel"), role: .cancel) {}
@@ -207,9 +244,14 @@ private struct RulePackEditor: View {
if draft.configuration.scope == .selectedApps {
HStack {
TextField(L("App name, bundle identifier, or process"), text: $draft.typedApp)
- .onSubmit { addApp() }
+ .onSubmit { if appsLoaded { addApp() } }
+ if !appsScanned {
+ ProgressView().controlSize(.small)
+ } else if !appsLoaded {
+ Button(L("Refresh")) { loadApps() }.controlSize(.small)
+ }
Button(L("Add")) { addApp() }
- .disabled(draft.typedApp.trimmingCharacters(in: .whitespaces).isEmpty)
+ .disabled(!appsLoaded || draft.typedApp.trimmingCharacters(in: .whitespaces).isEmpty)
}
if !draft.typedApp.isEmpty, !appSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
@@ -249,7 +291,11 @@ private struct RulePackEditor: View {
}
@ViewBuilder private var previewSection: some View {
- if let preview {
+ if let previewError {
+ Label(previewError, systemImage: "exclamationmark.triangle")
+ .font(.caption).foregroundStyle(FL.critical)
+ .padding(10).background(FL.critical.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
+ } else if let preview {
VStack(alignment: .leading, spacing: 5) {
Text(L("Recent-traffic impact")).font(.headline)
if preview.connectionsConsidered == 0 {
@@ -302,12 +348,24 @@ private struct RulePackEditor: View {
}.prefix(5))
}
+ private func loadApps() {
+ appsScanned = false
+ appsLoaded = false
+ Task {
+ let result = await Task.detached(priority: .userInitiated) { InstalledApps.scan() }.value
+ installed = result.apps
+ appsLoaded = result.complete
+ appsScanned = true
+ }
+ }
+
private func addApp(_ value: String? = nil) {
- if let value { _ = draft.addApp(value, installed: installed) }
- else { _ = draft.submitApp(installed: installed) }
+ if let value { _ = draft.addApp(value, installed: installed, appsLoaded: appsLoaded) }
+ else { _ = draft.submitApp(installed: installed, appsLoaded: appsLoaded) }
}
private func save() {
+ saveError = nil
switch draft.requestSave() {
case .commit(let configuration): commit(configuration)
case .confirmBroadening: confirmBroadening = true
@@ -317,24 +375,50 @@ private struct RulePackEditor: View {
private func commit(_ configuration: RulePackConfiguration? = nil) {
saving = true
+ saveError = nil
let value = configuration ?? draft.configuration
packs.save(value) { success in
saving = false
- if success { dismiss() }
+ if success {
+ dismiss()
+ } else {
+ saveError = packs.failureMessage ?? L("Failed: %@", L("Save"))
+ }
}
}
+ private func invalidatePreview() {
+ preview = nil
+ previewError = nil
+ }
+
private func runPreview() async {
+ let candidate = draft.configuration
+ let packVersion = packs.version
+ let ruleVersion = rules.version
previewing = true
+ preview = nil
+ previewError = nil
defer { previewing = false }
- let input = try? await monitor.read {
- (try $0.flowsForSimulation(since: Date().addingTimeInterval(-7 * 86400)), try $0.loadPolicies())
+ do {
+ let input = try await monitor.read {
+ (try $0.flowsForSimulation(since: Date().addingTimeInterval(-7 * 86400)),
+ try $0.loadPolicies())
+ }
+ guard candidate == draft.configuration,
+ packVersion == packs.version,
+ ruleVersion == rules.version else { return }
+ preview = RulePackSimulation.run(candidate: candidate, definitions: packs.definitions,
+ configurations: packs.configurations,
+ overrides: packs.overrides, rules: rules.rules,
+ policies: input.1, paused: rules.isPaused,
+ flows: input.0, session: RuleStore.session)
+ } catch {
+ guard candidate == draft.configuration,
+ packVersion == packs.version,
+ ruleVersion == rules.version else { return }
+ previewError = L("Failed: %@", L("Test against recent traffic"))
}
- preview = RulePackSimulation.run(candidate: draft.configuration, definitions: packs.definitions,
- configurations: packs.configurations,
- overrides: packs.overrides, rules: monitor.rules.rules,
- policies: input?.1 ?? [:], paused: monitor.rules.isPaused,
- flows: input?.0 ?? [], session: RuleStore.session)
}
}
diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift
index b7e133e5..cfaa4bee 100644
--- a/FlowlightTests/RulePackTests.swift
+++ b/FlowlightTests/RulePackTests.swift
@@ -363,7 +363,7 @@ final class RulePackTests: XCTestCase {
XCTAssertFalse(draft.configuration.enabled)
XCTAssertEqual(draft.configuration.scope, .allApps)
- XCTAssertNil(draft.saveAction)
+ XCTAssertEqual(draft.requestSave(), .commit(saved))
}
func testEditorActivationAndCancelNeverMutateSavedConfiguration() {
@@ -387,12 +387,40 @@ final class RulePackTests: XCTestCase {
XCTAssertEqual(draft.submitApp(installed: []), .added("com.apple.curl"))
XCTAssertEqual(draft.configuration.apps, ["com.apple.curl"])
XCTAssertEqual(draft.typedApp, "")
- XCTAssertNil(draft.saveAction, "Return in the app field must not save or dismiss the editor")
draft.typedApp = "com.example.second"
XCTAssertEqual(draft.submitApp(installed: []), .added("com.example.second"))
XCTAssertEqual(draft.configuration.apps, ["com.apple.curl", "com.example.second"])
- XCTAssertNil(draft.saveAction, "Return must remain Add even after Save becomes valid")
+ XCTAssertEqual(draft.requestSave(), .commit(draft.configuration),
+ "Return only edits the draft; explicit Save decides whether to commit")
+ }
+
+ func testConcurrentCardSavesStayTrackedIndependently() {
+ var saves = RulePackSaveTracker()
+ saves.begin("official.advertising")
+ saves.begin("official.tracking")
+
+ saves.finish("official.advertising")
+
+ XCTAssertFalse(saves.contains("official.advertising"))
+ XCTAssertTrue(saves.contains("official.tracking"))
+ }
+
+ func testEditorWaitsForInstalledAppsBeforeResolvingDisplayName() {
+ let safari = InstalledApps.App(name: "Safari", bundleID: "com.apple.Safari",
+ path: "/Applications/Safari.app")
+ var draft = RulePackEditorDraft(saved: RulePackConfiguration(packID: "official.advertising"),
+ intent: .activate)
+ draft.configuration.scope = .selectedApps
+ draft.typedApp = "Safari"
+
+ XCTAssertEqual(draft.submitApp(installed: [], appsLoaded: false), .loading)
+ XCTAssertEqual(draft.typedApp, "Safari")
+ XCTAssertTrue(draft.configuration.apps.isEmpty)
+
+ XCTAssertEqual(draft.submitApp(installed: [safari], appsLoaded: true), .added("com.apple.Safari"))
+ XCTAssertEqual(draft.configuration.apps, ["com.apple.Safari"])
+ XCTAssertEqual(draft.typedApp, "")
}
func testEditorExplicitSaveAndBroadeningDecision() {
@@ -405,8 +433,8 @@ final class RulePackTests: XCTestCase {
XCTAssertEqual(draft.requestSave(), .confirmBroadening)
selected.apps = []
- var invalid = RulePackEditorDraft(saved: selected, intent: .review)
- XCTAssertNil(invalid.saveAction)
+ let invalid = RulePackEditorDraft(saved: selected, intent: .review)
+ XCTAssertNil(invalid.requestSave())
XCTAssertFalse(invalid.canSave)
}
From 54bc6dc695f247cdf0ea401d576ea552593784fa Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Sun, 18 Oct 2026 08:39:28 -0700
Subject: [PATCH 4/5] Keep pack save failures visible
Co-Authored-By: Claude Code
---
Flowlight/UI/RulePacksView.swift | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift
index 4d4a0cd9..26d92597 100644
--- a/Flowlight/UI/RulePacksView.swift
+++ b/Flowlight/UI/RulePacksView.swift
@@ -49,7 +49,6 @@ private struct RulePacksContent: View {
RulePackEditor(pack: request.pack, saved: packs.configuration(for: request.pack.id),
intent: request.intent, packs: packs, rules: monitor.rules)
}
- .onChange(of: packs.version) { _, _ in saveErrors.removeAll() }
}
private var explanation: some View {
@@ -79,6 +78,7 @@ private struct RulePacksContent: View {
Spacer(minLength: 8)
Toggle("", isOn: Binding(get: { configuration.enabled }, set: { enabled in
if enabled {
+ saveErrors[pack.id] = nil
editor = RulePackEditorRequest(pack: pack, intent: .activate)
} else {
var changed = configuration
@@ -102,6 +102,7 @@ private struct RulePacksContent: View {
}
Spacer()
Button(L("Review…")) {
+ saveErrors[pack.id] = nil
editor = RulePackEditorRequest(pack: pack, intent: .review)
}.disabled(isSaving)
}
From 8555829331663e390c3a86ec0f6d4a8afc4632ed Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Sun, 18 Oct 2026 15:29:23 -0700
Subject: [PATCH 5/5] Release Flowlight 0.15.2
Repair managed-pack activity schemas by verifying the physical SQLite key, keep app discovery and exception persistence truthful under partial failures, and publish the 0.15.2 release metadata.
Co-Authored-By: Claude Code
---
Flowlight/Analysis/InstalledApps.swift | 67 +++++--
Flowlight/Analysis/RulePackStore.swift | 40 ++--
Flowlight/Storage/TrafficDatabase.swift | 43 +++--
Flowlight/UI/RuleEditor.swift | 6 +-
Flowlight/UI/RulePackEditorDraft.swift | 11 +-
Flowlight/UI/RulePacksView.swift | 56 ++++--
Flowlight/UI/RulesView.swift | 14 +-
FlowlightTests/InstalledAppsTests.swift | 50 +++++
FlowlightTests/RulePackTests.swift | 233 +++++++++++++++++++++++-
docs/404.html | 2 +-
docs/about/index.html | 2 +-
docs/de/about/index.html | 2 +-
docs/de/docs/index.html | 4 +-
docs/de/index.html | 6 +-
docs/de/privacy/index.html | 2 +-
docs/de/threat-model/index.html | 2 +-
docs/docs/index.html | 4 +-
docs/es/about/index.html | 2 +-
docs/es/docs/index.html | 4 +-
docs/es/index.html | 6 +-
docs/es/privacy/index.html | 2 +-
docs/es/threat-model/index.html | 2 +-
docs/fr/about/index.html | 2 +-
docs/fr/docs/index.html | 4 +-
docs/fr/index.html | 6 +-
docs/fr/privacy/index.html | 2 +-
docs/fr/threat-model/index.html | 2 +-
docs/index.html | 6 +-
docs/it/about/index.html | 2 +-
docs/it/docs/index.html | 4 +-
docs/it/index.html | 6 +-
docs/it/privacy/index.html | 2 +-
docs/it/threat-model/index.html | 2 +-
docs/ja/about/index.html | 2 +-
docs/ja/docs/index.html | 4 +-
docs/ja/index.html | 6 +-
docs/ja/privacy/index.html | 2 +-
docs/ja/threat-model/index.html | 2 +-
docs/ko/about/index.html | 2 +-
docs/ko/docs/index.html | 4 +-
docs/ko/index.html | 6 +-
docs/ko/privacy/index.html | 2 +-
docs/ko/threat-model/index.html | 2 +-
docs/llms-full.txt | 20 +-
docs/llms.txt | 2 +-
docs/plugins/index.html | 2 +-
docs/privacy/index.html | 2 +-
docs/pt-PT/about/index.html | 2 +-
docs/pt-PT/docs/index.html | 4 +-
docs/pt-PT/index.html | 6 +-
docs/pt-PT/privacy/index.html | 2 +-
docs/pt-PT/threat-model/index.html | 2 +-
docs/releases/index.html | 18 +-
docs/threat-model/index.html | 2 +-
docs/zh-Hans/about/index.html | 2 +-
docs/zh-Hans/docs/index.html | 4 +-
docs/zh-Hans/index.html | 6 +-
docs/zh-Hans/privacy/index.html | 2 +-
docs/zh-Hans/threat-model/index.html | 2 +-
docs/zh-Hant/about/index.html | 2 +-
docs/zh-Hant/docs/index.html | 4 +-
docs/zh-Hant/index.html | 6 +-
docs/zh-Hant/privacy/index.html | 2 +-
docs/zh-Hant/threat-model/index.html | 2 +-
project.yml | 4 +-
site/pages/releases.html | 16 +-
66 files changed, 588 insertions(+), 156 deletions(-)
diff --git a/Flowlight/Analysis/InstalledApps.swift b/Flowlight/Analysis/InstalledApps.swift
index 4fc96db3..05319f40 100644
--- a/Flowlight/Analysis/InstalledApps.swift
+++ b/Flowlight/Analysis/InstalledApps.swift
@@ -22,52 +22,81 @@ enum InstalledApps {
var complete: Bool
}
- private static let lock = NSLock()
+ private static let condition = NSCondition()
private static var cached: ScanResult?
+ private static var scanning = false
/// Where applications live. The system folder is included because Safari and Mail are perfectly reasonable
/// things to focus on, and they aren't in /Applications.
private static var searchPaths: [String] {
- ["/Applications", "/Applications/Utilities", "/System/Applications", "/System/Applications/Utilities",
- NSHomeDirectory() + "/Applications"]
+ ["/Applications", "/System/Applications", NSHomeDirectory() + "/Applications"]
}
- /// Scanned once and kept. A Mac doesn't gain applications while a popover is open, and re-reading several
- /// hundred Info.plists on every keystroke would be felt.
+ /// A complete catalog is retained for synchronous lookups. Callers that can be first to ask load off the main
+ /// actor; a partial scan is returned to that caller but never poisons every later lookup.
static func all() -> [App] { scan().apps }
- static func scan() -> ScanResult {
- lock.lock()
- if let cached { lock.unlock(); return cached }
- lock.unlock()
+ static func scan(refresh: Bool = false) -> ScanResult {
+ cachedScan(refresh: refresh) { scan(in: searchPaths) }
+ }
+
+ static func cachedScan(refresh: Bool = false, _ load: () -> ScanResult) -> ScanResult {
+ condition.lock()
+ if !refresh, let cached { condition.unlock(); return cached }
+ if scanning {
+ repeat { condition.wait() } while scanning
+ if let cached { condition.unlock(); return cached }
+ }
+ scanning = true
+ condition.unlock()
+ let result = load()
+ condition.lock()
+ if result.complete { cached = result }
+ scanning = false
+ condition.broadcast()
+ condition.unlock()
+ return result
+ }
+
+ static func resetCacheForTesting() {
+ condition.lock()
+ cached = nil
+ condition.unlock()
+ }
+
+ static func scan(in searchPaths: [String], manager: FileManager = .default) -> ScanResult {
var found: [String: App] = [:]
var complete = true
- let manager = FileManager.default
for directory in searchPaths {
guard manager.fileExists(atPath: directory) else { continue }
- guard let entries = try? manager.contentsOfDirectory(atPath: directory) else {
+ guard let enumerator = manager.enumerator(
+ at: URL(fileURLWithPath: directory),
+ includingPropertiesForKeys: [.isDirectoryKey, .isPackageKey],
+ options: [.skipsHiddenFiles],
+ errorHandler: { _, _ in complete = false; return true }
+ ) else {
complete = false
continue
}
- for entry in entries where entry.hasSuffix(".app") {
- let path = directory + "/" + entry
- guard let bundle = Bundle(path: path), let id = bundle.bundleIdentifier, !id.isEmpty else { continue }
+ for case let url as URL in enumerator {
+ guard url.pathExtension.caseInsensitiveCompare("app") == .orderedSame else { continue }
+ enumerator.skipDescendants()
+ guard let bundle = Bundle(url: url), let id = bundle.bundleIdentifier, !id.isEmpty else {
+ continue
+ }
let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String)
- ?? String(entry.dropLast(4))
+ ?? url.deletingPathExtension().lastPathComponent
let purpose = (bundle.object(forInfoDictionaryKey: "NSBluetoothAlwaysUsageDescription") as? String)
?? (bundle.object(forInfoDictionaryKey: "NSBluetoothPeripheralUsageDescription") as? String)
- found[id] = App(name: name, bundleID: id, path: path, bluetoothPurpose: purpose)
+ found[id] = App(name: name, bundleID: id, path: url.path, bluetoothPurpose: purpose)
}
}
let result = ScanResult(
apps: found.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending },
complete: complete && !found.isEmpty
)
- if result.complete {
- lock.lock(); cached = result; lock.unlock()
- }
return result
}
diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift
index acede341..0162cfd8 100644
--- a/Flowlight/Analysis/RulePackStore.swift
+++ b/Flowlight/Analysis/RulePackStore.swift
@@ -1,5 +1,14 @@
import Foundation
+extension RulePackOverride {
+ func hasSameScope(as other: RulePackOverride) -> Bool {
+ packID == other.packID
+ && app.caseInsensitiveCompare(other.app) == .orderedSame
+ && host.caseInsensitiveCompare(other.host) == .orderedSame
+ && match == other.match
+ }
+}
+
/// 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
@@ -110,25 +119,34 @@ final class RulePackStore: ObservableObject {
save(value)
}
- func addOverride(_ override: RulePackOverride) {
+ func addOverride(_ override: RulePackOverride, completion: ((Bool) -> Void)? = nil) {
guard definitions.contains(where: { $0.id == override.packID }),
- let host = HostCanonicalizer.host(override.host) else { return }
+ let host = HostCanonicalizer.host(override.host) else {
+ completion?(false)
+ 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 }
+ persist({ try $0.saveRulePackOverride(cleaned) }, then: { [weak self] in
+ guard let self else { completion?(false); return }
+ self.overrides.removeAll { $0.id == cleaned.id || $0.hasSameScope(as: cleaned) }
self.overrides.append(cleaned)
self.publish()
- }
+ completion?(true)
+ }, onFailure: {
+ completion?(false)
+ })
}
- func deleteOverride(_ override: RulePackOverride) {
- persist({ try $0.deleteRulePackOverride(override.id) }) { [weak self] in
- self?.overrides.removeAll { $0.id == override.id }
+ func deleteOverride(_ override: RulePackOverride, completion: ((Bool) -> Void)? = nil) {
+ persist({ try $0.deleteRulePackOverride(override) }, then: { [weak self] in
+ self?.overrides.removeAll { $0.hasSameScope(as: override) }
self?.publish()
- }
+ completion?(true)
+ }, onFailure: {
+ completion?(false)
+ })
}
func updateStatus(_ newStatus: ApplyStatus) { status = newStatus }
@@ -201,7 +219,7 @@ final class RulePackStore: ObservableObject {
Task { @MainActor in commit() }
} catch {
Task { @MainActor in
- self?.status = .failed("Could not save the rule pack change: \(error.localizedDescription)")
+ self?.status = .failed("Could not save the rule pack change: \(String(describing: error))")
onFailure?()
}
}
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index b24939c4..cd7a82d9 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -225,6 +225,11 @@ struct RulePackEventRecord: Identifiable, Sendable, Hashable {
/// flows_1s → agg_1m (complete minutes) → agg_1h / agg_1d (folded from immutable minute rows).
final class TrafficDatabase: @unchecked Sendable {
static let tables = ["flows_1s", "agg_1m", "agg_1h", "agg_1d"]
+ private static let rulePackEventKey = [
+ "bucket", "pack_id", "pack_version", "entry_id", "category", "match", "action",
+ "bundle_id", "app_name", "agent", "agent_name", "host", "matched_host", "port",
+ ]
+ private static let rulePackEventKeySQL = rulePackEventKey.joined(separator: ", ")
let queue = DispatchQueue(label: "flowlight.db", qos: .userInitiated)
private let conn: SQLiteConnection
let url: URL
@@ -490,8 +495,7 @@ final class TrafficDatabase: @unchecked Sendable {
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));
+ UNIQUE(\(Self.rulePackEventKeySQL)));
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);
@@ -502,7 +506,16 @@ final class TrafficDatabase: @unchecked Sendable {
"SELECT watermark FROM rollup_state WHERE tier = 'schema.rulePackEventKey'",
map: { $0.int(0) }
).first ?? 0
- if packEventKeyVersion < 2 {
+ let packEventIndexes = try conn.query("PRAGMA index_list(rule_pack_event_rollups)") {
+ (name: $0.text(1), unique: $0.int(2) == 1, partial: $0.int(4) == 1)
+ }
+ let hasCurrentPackEventKey = try packEventIndexes
+ .filter { $0.unique && !$0.partial }
+ .contains { index in
+ let quoted = index.name.replacingOccurrences(of: "'", with: "''")
+ return try conn.query("PRAGMA index_info('\(quoted)')") { $0.text(2) } == Self.rulePackEventKey
+ }
+ if packEventKeyVersion < 2 || !hasCurrentPackEventKey {
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 {
@@ -1056,14 +1069,23 @@ final class TrafficDatabase: @unchecked Sendable {
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))])
+ try conn.transaction {
+ for existing in try loadRulePackOverrides().filter({ $0.id != override.id && $0.hasSameScope(as: override) }) {
+ try conn.run("DELETE FROM rule_pack_overrides WHERE id = ?", [.text(existing.id.uuidString)])
+ }
+ 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)])
+ func deleteRulePackOverride(_ override: RulePackOverride) throws {
+ try conn.transaction {
+ for existing in try loadRulePackOverrides().filter({ $0.hasSameScope(as: override) }) {
+ try conn.run("DELETE FROM rule_pack_overrides WHERE id = ?", [.text(existing.id.uuidString)])
+ }
+ }
}
/// Commits a retryable extension delivery exactly once. One monotonic watermark per extension process and the
@@ -1103,8 +1125,7 @@ final class TrafficDatabase: @unchecked Sendable {
(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
+ ON CONFLICT(\(Self.rulePackEventKeySQL)) DO UPDATE SET
count = count + excluded.count,
first_at = MIN(first_at, excluded.first_at),
last_at = MAX(last_at, excluded.last_at)
diff --git a/Flowlight/UI/RuleEditor.swift b/Flowlight/UI/RuleEditor.swift
index 80e97de8..0218ae4a 100644
--- a/Flowlight/UI/RuleEditor.swift
+++ b/Flowlight/UI/RuleEditor.swift
@@ -12,8 +12,7 @@ struct RuleEditor: View {
@Environment(\.dismiss) private var dismiss
@State private var appQuery = ""
@State private var showAdvanced = false
-
- private var apps: [InstalledApps.App] { InstalledApps.all() }
+ @State private var apps: [InstalledApps.App] = []
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -135,6 +134,9 @@ struct RuleEditor: View {
.padding(16)
.frame(width: 540)
.frame(minHeight: 520)
+ .task {
+ apps = await Task.detached(priority: .userInitiated) { InstalledApps.all() }.value
+ }
}
/// The rule, read back, above the fields that make it. An unfinished rule says so here rather than only at the
diff --git a/Flowlight/UI/RulePackEditorDraft.swift b/Flowlight/UI/RulePackEditorDraft.swift
index 92873225..ca31f83c 100644
--- a/Flowlight/UI/RulePackEditorDraft.swift
+++ b/Flowlight/UI/RulePackEditorDraft.swift
@@ -38,8 +38,15 @@ struct RulePackEditorDraft {
}
mutating func addApp(_ value: String, installed: [InstalledApps.App], appsLoaded: Bool = true) -> AppSubmission {
- guard appsLoaded else { return .loading }
- guard let target = InstalledApps.target(forTyped: value, apps: installed) else { return .ignored }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ let exactApp = installed.contains {
+ $0.bundleID.caseInsensitiveCompare(trimmed) == .orderedSame
+ || $0.name.caseInsensitiveCompare(trimmed) == .orderedSame
+ }
+ let explicitIdentity = trimmed.contains(".") && !trimmed.contains(where: \.isWhitespace)
+ || AgentCatalog.knownAgent(bundleID: trimmed, appName: trimmed) != nil
+ guard appsLoaded || exactApp || explicitIdentity else { return .loading }
+ guard let target = InstalledApps.target(forTyped: trimmed, apps: installed) else { return .ignored }
if !configuration.apps.contains(where: { $0.caseInsensitiveCompare(target.value) == .orderedSame }) {
configuration.apps.append(target.value)
configuration.apps.sort()
diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift
index 26d92597..23d01f3e 100644
--- a/Flowlight/UI/RulePacksView.swift
+++ b/Flowlight/UI/RulePacksView.swift
@@ -226,6 +226,7 @@ private struct RulePackEditor: View {
.onChange(of: draft.configuration) { _, _ in invalidatePreview() }
.onChange(of: packs.version) { _, _ in invalidatePreview() }
.onChange(of: rules.version) { _, _ in invalidatePreview() }
+ .onChange(of: monitor.dataVersion) { _, _ in invalidatePreview() }
.onAppear { loadApps() }
.confirmationDialog(L("Apply this pack to every app?"), isPresented: $confirmBroadening) {
Button(L("Apply to every app"), role: .destructive) { commit(draft.configuration) }
@@ -245,14 +246,14 @@ private struct RulePackEditor: View {
if draft.configuration.scope == .selectedApps {
HStack {
TextField(L("App name, bundle identifier, or process"), text: $draft.typedApp)
- .onSubmit { if appsLoaded { addApp() } }
+ .onSubmit { addApp() }
if !appsScanned {
ProgressView().controlSize(.small)
} else if !appsLoaded {
Button(L("Refresh")) { loadApps() }.controlSize(.small)
}
Button(L("Add")) { addApp() }
- .disabled(!appsLoaded || draft.typedApp.trimmingCharacters(in: .whitespaces).isEmpty)
+ .disabled(draft.typedApp.trimmingCharacters(in: .whitespaces).isEmpty)
}
if !draft.typedApp.isEmpty, !appSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
@@ -336,7 +337,16 @@ private struct RulePackEditor: View {
Text("→").foregroundStyle(.tertiary)
Text(exception.host).font(.caption.monospaced())
Spacer()
- Button(L("Delete"), role: .destructive) { packs.deleteOverride(exception) }
+ Button(L("Delete"), role: .destructive) {
+ saving = true
+ saveError = nil
+ packs.deleteOverride(exception) { success in
+ saving = false
+ if !success {
+ saveError = packs.failureMessage ?? L("Failed: %@", L("Delete"))
+ }
+ }
+ }
}.padding(.vertical, 2)
}
}
@@ -350,10 +360,11 @@ private struct RulePackEditor: View {
}
private func loadApps() {
+ let refresh = appsScanned
appsScanned = false
appsLoaded = false
Task {
- let result = await Task.detached(priority: .userInitiated) { InstalledApps.scan() }.value
+ let result = await Task.detached(priority: .userInitiated) { InstalledApps.scan(refresh: refresh) }.value
installed = result.apps
appsLoaded = result.complete
appsScanned = true
@@ -397,6 +408,7 @@ private struct RulePackEditor: View {
let candidate = draft.configuration
let packVersion = packs.version
let ruleVersion = rules.version
+ let dataVersion = monitor.dataVersion
previewing = true
preview = nil
previewError = nil
@@ -408,7 +420,8 @@ private struct RulePackEditor: View {
}
guard candidate == draft.configuration,
packVersion == packs.version,
- ruleVersion == rules.version else { return }
+ ruleVersion == rules.version,
+ dataVersion == monitor.dataVersion else { return }
preview = RulePackSimulation.run(candidate: candidate, definitions: packs.definitions,
configurations: packs.configurations,
overrides: packs.overrides, rules: rules.rules,
@@ -417,8 +430,9 @@ private struct RulePackEditor: View {
} catch {
guard candidate == draft.configuration,
packVersion == packs.version,
- ruleVersion == rules.version else { return }
- previewError = L("Failed: %@", L("Test against recent traffic"))
+ ruleVersion == rules.version,
+ dataVersion == monitor.dataVersion else { return }
+ previewError = L("Failed: %@", String(describing: error))
}
}
}
@@ -428,6 +442,8 @@ struct RulePackEventRow: View {
@EnvironmentObject var monitor: TrafficMonitor
@EnvironmentObject var nav: AppNavigation
@State private var recentOverride: RulePackOverride?
+ @State private var overrideSaving = false
+ @State private var overrideError: String?
var body: some View {
HStack(alignment: .top, spacing: 10) {
@@ -444,9 +460,14 @@ struct RulePackEventRow: View {
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)
+ overrideSaving = true
+ overrideError = nil
+ monitor.rulePacks.deleteOverride(recentOverride) { success in
+ overrideSaving = false
+ if success { self.recentOverride = nil }
+ else { overrideError = monitor.rulePacks.failureMessage ?? L("Failed: %@", L("Delete")) }
+ }
+ }.buttonStyle(.link).font(.caption).disabled(overrideSaving)
} else {
Menu {
Button(L("Allow for %@", event.appName)) { allowForApp() }
@@ -456,7 +477,11 @@ struct RulePackEventRow: View {
} label: {
Text(L("Allow…"))
}
- .menuStyle(.borderlessButton).fixedSize().font(.caption)
+ .menuStyle(.borderlessButton).fixedSize().font(.caption).disabled(overrideSaving)
+ }
+ if overrideSaving { ProgressView().controlSize(.mini) }
+ if let overrideError {
+ Text(overrideError).font(.caption2).foregroundStyle(FL.critical)
}
}
}.padding(.horizontal, 10).padding(.vertical, 8)
@@ -465,8 +490,13 @@ struct RulePackEventRow: View {
private func allowForApp() {
let exception = RulePackOverride(packID: event.packID, app: event.bundleID,
host: event.matchedHost, match: event.match)
- recentOverride = exception
- monitor.rulePacks.addOverride(exception)
+ overrideSaving = true
+ overrideError = nil
+ monitor.rulePacks.addOverride(exception) { success in
+ overrideSaving = false
+ if success { recentOverride = exception }
+ else { overrideError = monitor.rulePacks.failureMessage ?? L("Failed: %@", L("Save")) }
+ }
}
private var packName: String {
diff --git a/Flowlight/UI/RulesView.swift b/Flowlight/UI/RulesView.swift
index 33ed64e7..16ba75c2 100644
--- a/Flowlight/UI/RulesView.swift
+++ b/Flowlight/UI/RulesView.swift
@@ -693,6 +693,7 @@ struct RuleTemplateLibrary: View {
@State private var app = ""
@State private var destination = ""
@State private var added: Set = []
+ @State private var installed: [InstalledApps.App] = []
/// The subject as the cards see it. The readable name is looked up rather than stored, so typing over the app
/// can't leave a rule named after the one that was there before.
@@ -704,8 +705,7 @@ struct RuleTemplateLibrary: View {
if bundleID.caseInsensitiveCompare(subject.app) == .orderedSame, !subject.appName.isEmpty {
return subject.appName
}
- return InstalledApps.all().first { $0.bundleID.caseInsensitiveCompare(bundleID) == .orderedSame }?.name
- ?? bundleID
+ return installed.first { $0.bundleID.caseInsensitiveCompare(bundleID) == .orderedSame }?.name ?? bundleID
}
var body: some View {
@@ -737,6 +737,9 @@ struct RuleTemplateLibrary: View {
}
.frame(width: Measure.prose)
.frame(minHeight: 560)
+ .task {
+ installed = await Task.detached(priority: .userInitiated) { InstalledApps.all() }.value
+ }
}
private var header: some View {
@@ -862,10 +865,9 @@ struct RuleTemplateLibrary: View {
}
private var suggestions: [InstalledApps.App] {
- let all = InstalledApps.all()
- guard !app.isEmpty, !all.contains(where: { $0.bundleID.caseInsensitiveCompare(app) == .orderedSame })
- else { return [] }
- return Array(InstalledApps.search(app, in: all).prefix(4))
+ guard !app.isEmpty,
+ !installed.contains(where: { $0.bundleID.caseInsensitiveCompare(app) == .orderedSame }) else { return [] }
+ return Array(InstalledApps.search(app, in: installed).prefix(4))
}
/// Every rule, written out. Long lists are the whole point of a template, so they aren't truncated — an
diff --git a/FlowlightTests/InstalledAppsTests.swift b/FlowlightTests/InstalledAppsTests.swift
index 02509934..c265c6f8 100644
--- a/FlowlightTests/InstalledAppsTests.swift
+++ b/FlowlightTests/InstalledAppsTests.swift
@@ -33,6 +33,56 @@ final class InstalledAppsTests: XCTestCase {
func testBlankInputIsRefused() {
XCTAssertNil(InstalledApps.target(forTyped: " ", apps: apps))
}
+
+ func testPartialScanDoesNotPoisonTheCompleteCache() {
+ InstalledApps.resetCacheForTesting()
+ defer { InstalledApps.resetCacheForTesting() }
+ let partial = InstalledApps.ScanResult(apps: [apps[0]], complete: false)
+ let complete = InstalledApps.ScanResult(apps: apps, complete: true)
+ var loads = 0
+
+ XCTAssertEqual(InstalledApps.cachedScan { loads += 1; return partial }.apps, [apps[0]])
+ XCTAssertEqual(InstalledApps.cachedScan { loads += 1; return complete }.apps, apps)
+ XCTAssertEqual(InstalledApps.cachedScan { loads += 1; return partial }.apps, apps)
+ XCTAssertEqual(loads, 2)
+ }
+
+ func testMalformedNestedAppDoesNotMakeTheCatalogIncomplete() throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("installed-apps-broken-\(UUID().uuidString)")
+ let valid = root.appendingPathComponent("Setapp/Sample.app/Contents")
+ let broken = root.appendingPathComponent("Setapp/Broken.app")
+ defer { try? FileManager.default.removeItem(at: root) }
+ try FileManager.default.createDirectory(at: valid, withIntermediateDirectories: true)
+ try FileManager.default.createDirectory(at: broken, withIntermediateDirectories: true)
+ let plist: [String: Any] = ["CFBundleIdentifier": "com.example.Sample", "CFBundleName": "Sample"]
+ let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ try data.write(to: valid.appendingPathComponent("Info.plist"))
+
+ let result = InstalledApps.scan(in: [root.path])
+
+ XCTAssertTrue(result.complete)
+ XCTAssertEqual(result.apps.map(\.bundleID), ["com.example.Sample"])
+ }
+
+ func testScanFindsAppsBelowCollectionDirectories() throws {
+ let root = FileManager.default.temporaryDirectory.appendingPathComponent("installed-apps-\(UUID().uuidString)")
+ let nested = root.appendingPathComponent("Setapp/Sample.app")
+ defer { try? FileManager.default.removeItem(at: root) }
+ try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true)
+ let plist: [String: Any] = [
+ "CFBundleIdentifier": "com.example.Sample",
+ "CFBundleName": "Sample",
+ ]
+ let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
+ let contents = nested.appendingPathComponent("Contents")
+ try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true)
+ try data.write(to: contents.appendingPathComponent("Info.plist"))
+
+ let result = InstalledApps.scan(in: [root.path])
+
+ XCTAssertTrue(result.complete)
+ XCTAssertEqual(result.apps.map(\.bundleID), ["com.example.Sample"])
+ }
}
/// Focus is symmetric: either half alone is a complete focus.
diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift
index cfaa4bee..ceebc79a 100644
--- a/FlowlightTests/RulePackTests.swift
+++ b/FlowlightTests/RulePackTests.swift
@@ -184,10 +184,113 @@ final class RulePackTests: XCTestCase {
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)
+ try reopened.deleteRulePackOverride(override)
XCTAssertTrue(try reopened.loadRulePackOverrides().isEmpty)
}
+ func testStalePackEventMarkerRepairsThePhysicalUniqueKey() throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-stale-key-\(UUID().uuidString).sqlite")
+ defer {
+ for suffix in ["", "-wal", "-shm"] {
+ try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + suffix))
+ }
+ }
+
+ do {
+ let fixture = try SQLiteConnection(path: url.path)
+ try fixture.execute("""
+ CREATE TABLE rollup_state (tier TEXT PRIMARY KEY, watermark INTEGER NOT NULL);
+ INSERT INTO rollup_state (tier, watermark) VALUES ('schema.rulePackEventKey', 2);
+ CREATE TABLE 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, action, bundle_id, app_name,
+ agent, agent_name, host, matched_host, port));
+ 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 INTO rule_pack_event_rollups
+ (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)
+ VALUES (41, 1700000000, 'official.ads', '1', 7, 'advertising', 'suffix', 'block', 3,
+ 1700000001, 1700000020, 'com.apple.Safari', 'Safari', 'com.apple.Safari', 'Safari',
+ 'cdn.ads.example', 'ads.example', 443);
+ """)
+ XCTAssertThrowsError(try fixture.run("""
+ 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
+ """, [.int(1_700_000_000), .text("official.ads"), .text("1"), .int(7),
+ .text("advertising"), .text("suffix"), .text("block"), .int(1),
+ .int(1_700_000_001), .int(1_700_000_020), .text("com.apple.Safari"),
+ .text("Safari"), .text("com.apple.Safari"), .text("Safari"),
+ .text("cdn.ads.example"), .text("ads.example"), .int(443)])) { error in
+ XCTAssertTrue(String(describing: error).contains("ON CONFLICT clause does not match"))
+ }
+ }
+
+ var repaired: TrafficDatabase? = try TrafficDatabase(url: url)
+ let suffix = RulePackEvent(
+ bucket: 1_700_000_000, packID: "official.ads", packVersion: "1", entryID: 7,
+ category: .advertising, match: .suffix, action: .block, count: 2,
+ firstAt: 1_699_999_999, 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)
+ var exact = suffix
+ exact.match = .exact
+ exact.count = 4
+ try repaired?.recordRulePackEvents([suffix, exact])
+
+ var events = try XCTUnwrap(repaired).rulePackEvents()
+ XCTAssertEqual(events.count, 2)
+ XCTAssertEqual(events.first { $0.match == .suffix }?.id, 41)
+ XCTAssertEqual(events.first { $0.match == .suffix }?.count, 5)
+ XCTAssertEqual(events.first { $0.match == .suffix }?.firstAt,
+ Date(timeIntervalSince1970: 1_699_999_999))
+ XCTAssertEqual(events.first { $0.match == .suffix }?.lastAt,
+ Date(timeIntervalSince1970: 1_700_000_030))
+ XCTAssertEqual(events.first { $0.match == .exact }?.count, 4)
+
+ let indexes = try XCTUnwrap(repaired).raw(
+ "PRAGMA index_list(rule_pack_event_rollups)",
+ map: { (name: $0.text(1), unique: $0.int(2), partial: $0.int(4)) }
+ )
+ let expected = ["bucket", "pack_id", "pack_version", "entry_id", "category", "match", "action",
+ "bundle_id", "app_name", "agent", "agent_name", "host", "matched_host", "port"]
+ let matching = try indexes.filter { $0.unique == 1 && $0.partial == 0 }.contains { index in
+ let escaped = index.name.replacingOccurrences(of: "'", with: "''")
+ let columns = try XCTUnwrap(repaired).raw("PRAGMA index_info('\(escaped)')") { $0.text(2) }
+ return columns == expected
+ }
+ XCTAssertTrue(matching)
+ XCTAssertEqual(Set(indexes.map(\.name)).intersection(["rule_pack_events_time", "rule_pack_events_pack"]),
+ Set(["rule_pack_events_time", "rule_pack_events_pack"]))
+ XCTAssertEqual(try XCTUnwrap(repaired).raw(
+ "SELECT watermark FROM rollup_state WHERE tier = 'schema.rulePackEventKey'", map: { $0.int(0) }
+ ), [2])
+
+ repaired = nil
+ let reopened = try TrafficDatabase(url: url)
+ try reopened.recordRulePackEvents([suffix])
+ let batch = RulePackEventBatch(events: [exact], droppedHits: 2, droppedKeys: 1)
+ XCTAssertTrue(try reopened.recordRulePackEventBatch(batch))
+ XCTAssertFalse(try reopened.recordRulePackEventBatch(batch))
+ XCTAssertEqual(try reopened.rulePackDeliveryStats(), RulePackDeliveryStats(droppedHits: 2, droppedKeys: 1))
+ events = try reopened.rulePackEvents()
+ XCTAssertEqual(events.count, 2)
+ XCTAssertEqual(events.first { $0.match == .suffix }?.count, 7)
+ XCTAssertEqual(events.first { $0.match == .suffix }?.id, 41)
+ XCTAssertEqual(events.first { $0.match == .exact }?.count, 8)
+ }
+
@MainActor
func testBuiltInPacksValidateAndDefaultOff() throws {
XCTAssertNoThrow(try RulePackValidator.validate(BuiltInRulePacks.all))
@@ -275,6 +378,91 @@ final class RulePackTests: XCTestCase {
try await waitUntil { completed }
}
+ @MainActor
+ func testOverrideAppearsOnlyAfterDatabaseCommit() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-override-failure-\(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 }
+ _ = try db.raw("DROP TABLE rule_pack_overrides") { _ in 0 }
+
+ let exception = RulePackOverride(packID: "official.advertising", app: "com.apple.Safari",
+ host: "ads.example")
+ var result: Bool?
+ store.addOverride(exception) { result = $0 }
+
+ try await waitUntil { result != nil }
+ XCTAssertEqual(result, false)
+ XCTAssertTrue(store.overrides.isEmpty)
+ XCTAssertTrue(store.failureMessage?.contains("no such table") == true)
+ }
+
+ @MainActor
+ func testRepeatedOverrideReplacesAndDeleteRemovesItsWholeScope() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-override-dedupe-\(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 }
+
+ let first = RulePackOverride(packID: "official.advertising", app: "com.apple.Safari",
+ host: "ADS.EXAMPLE")
+ let second = RulePackOverride(packID: first.packID, app: "COM.APPLE.SAFARI",
+ host: "ads.example")
+ var added = 0
+ store.addOverride(first) { if $0 { added += 1 } }
+ try await waitUntil { added == 1 }
+ store.addOverride(second) { if $0 { added += 1 } }
+ try await waitUntil { added == 2 }
+
+ XCTAssertEqual(store.overrides, [second])
+ XCTAssertEqual(try db.sync { try $0.loadRulePackOverrides() }, [second])
+
+ let legacyJSON = String(decoding: try JSONEncoder().encode(first), as: UTF8.self)
+ _ = try db.raw(
+ "INSERT INTO rule_pack_overrides (id, override, updated) VALUES (?,?,?)",
+ [.text(first.id.uuidString), .text(legacyJSON), .int(1)]
+ ) { _ in 0 }
+ XCTAssertEqual(try db.sync { try $0.loadRulePackOverrides() }.count, 2)
+
+ var deleted: Bool?
+ store.deleteOverride(second) { deleted = $0 }
+ try await waitUntil { deleted != nil }
+ XCTAssertEqual(deleted, true)
+ XCTAssertTrue(store.overrides.isEmpty)
+ XCTAssertTrue(try db.sync { try $0.loadRulePackOverrides() }.isEmpty)
+ }
+
+ @MainActor
+ func testOverrideRemainsAfterDatabaseDeleteFails() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-override-delete-failure-\(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 }
+
+ let exception = RulePackOverride(packID: "official.advertising", app: "com.apple.Safari",
+ host: "ads.example")
+ var added: Bool?
+ store.addOverride(exception) { added = $0 }
+ try await waitUntil { added != nil }
+ XCTAssertEqual(added, true)
+ XCTAssertEqual(store.overrides, [exception])
+
+ _ = try db.raw("DROP TABLE rule_pack_overrides") { _ in 0 }
+ var deleted: Bool?
+ store.deleteOverride(exception) { deleted = $0 }
+
+ try await waitUntil { deleted != nil }
+ XCTAssertEqual(deleted, false)
+ XCTAssertEqual(store.overrides, [exception])
+ XCTAssertTrue(store.failureMessage?.contains("no such table") == true)
+ }
+
@MainActor
func testStorePublishesAfterTransientFailureRecovers() async throws {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("flowlight-pack-recovery-\(UUID().uuidString).sqlite")
@@ -423,6 +611,49 @@ final class RulePackTests: XCTestCase {
XCTAssertEqual(draft.typedApp, "")
}
+ func testEditorAcceptsExplicitBundleIdentityDuringIncompleteScan() {
+ var draft = RulePackEditorDraft(saved: RulePackConfiguration(packID: "official.advertising"),
+ intent: .activate)
+ draft.configuration.scope = .selectedApps
+
+ draft.typedApp = "com.apple.Safari"
+ XCTAssertEqual(draft.submitApp(installed: [], appsLoaded: false), .added("com.apple.Safari"))
+ XCTAssertEqual(draft.configuration.apps, ["com.apple.Safari"])
+ }
+
+ func testEditorIncompleteScanAcceptsKnownCommandLineAgent() {
+ var draft = RulePackEditorDraft(saved: RulePackConfiguration(packID: "official.advertising"),
+ intent: .activate)
+ draft.configuration.scope = .selectedApps
+ draft.typedApp = "claude"
+
+ XCTAssertEqual(draft.submitApp(installed: [], appsLoaded: false), .added("claude"))
+ XCTAssertEqual(draft.configuration.apps, ["claude"])
+ }
+
+ func testEditorIncompleteScanDoesNotGuessLowercaseDisplayNameIsAProcess() {
+ var draft = RulePackEditorDraft(saved: RulePackConfiguration(packID: "official.advertising"),
+ intent: .activate)
+ draft.configuration.scope = .selectedApps
+ draft.typedApp = "zoom"
+
+ XCTAssertEqual(draft.submitApp(installed: [], appsLoaded: false), .loading)
+ XCTAssertEqual(draft.typedApp, "zoom")
+ XCTAssertTrue(draft.configuration.apps.isEmpty)
+ }
+
+ func testEditorIncompleteScanStillResolvesKnownDisplayName() {
+ let safari = InstalledApps.App(name: "Safari", bundleID: "com.apple.Safari",
+ path: "/System/Applications/Safari.app")
+ var draft = RulePackEditorDraft(saved: RulePackConfiguration(packID: "official.advertising"),
+ intent: .activate)
+ draft.configuration.scope = .selectedApps
+ draft.typedApp = "Safari"
+
+ XCTAssertEqual(draft.submitApp(installed: [safari], appsLoaded: false), .added("com.apple.Safari"))
+ XCTAssertEqual(draft.configuration.apps, ["com.apple.Safari"])
+ }
+
func testEditorExplicitSaveAndBroadeningDecision() {
var selected = RulePackConfiguration(packID: "official.advertising", enabled: true,
scope: .selectedApps, apps: ["com.apple.curl"])
diff --git a/docs/404.html b/docs/404.html
index f58b62be..f2cf6db1 100644
--- a/docs/404.html
+++ b/docs/404.html
@@ -86,7 +86,7 @@
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 5cc393a3..df734bf8 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -62,7 +62,7 @@ Documentation
Using Flowlight
- Everything from the first launch to tuning the agent rules. Flowlight 0.15.1, macOS 15 or later.
+ Everything from the first launch to tuning the agent rules. Flowlight 0.15.2, macOS 15 or later.
On this page
@@ -887,7 +887,7 @@ Understand your AI agents.
brew install --cask xinbetween/tap/flowlightCopy
- v0.15.1macOS 15+UniversalGPL-3.0No telemetry
+ v0.15.2macOS 15+UniversalGPL-3.0No telemetry
connectionslive
@@ -1617,9 +1617,23 @@ Releases
Downloads, checksums and full notes for each version are on GitHub Releases.
- 0.15.1
+ 0.15.2
October 18, 2026Latest
+ Repair managed-pack activity on upgraded databases. Flowlight now verifies the physical
+ activity key during startup and atomically repairs stale schemas, preserving existing rows before any
+ blocked connection is recorded.
+
+ Keep pack decisions distinct and exactly once. Exact and suffix matches retain separate
+ provenance, repeated decisions aggregate correctly, and retried extension deliveries do not double count.
+
+ Keep editor feedback truthful under partial failures. Incomplete app scans still accept
+ explicit bundle identifiers, previews expire when traffic or policies change, and exceptions appear only after their
+ database write succeeds with a concrete error when it does not.
+
+ 0.15.1
+October 18, 2026
+
Keep managed-pack changes safely in review. Opening or cancelling an off pack no longer
activates it, and configuration changes persist only after an explicit Save succeeds.
diff --git a/docs/llms.txt b/docs/llms.txt
index b6693510..ae2a4a64 100644
--- a/docs/llms.txt
+++ b/docs/llms.txt
@@ -1,6 +1,6 @@
# Flowlight
-> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.1.
+> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.15.2.
- [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg)
- [Source code](https://github.com/xinbetween/flowlight)
diff --git a/docs/plugins/index.html b/docs/plugins/index.html
index 0ba7b180..b7040ab0 100644
--- a/docs/plugins/index.html
+++ b/docs/plugins/index.html
@@ -211,7 +211,7 @@
Repair managed-pack activity on upgraded databases. Flowlight now verifies the physical
+ activity key during startup and atomically repairs stale schemas, preserving existing rows before any
+ blocked connection is recorded.
+
Keep pack decisions distinct and exactly once. Exact and suffix matches retain separate
+ provenance, repeated decisions aggregate correctly, and retried extension deliveries do not double count.
+
Keep editor feedback truthful under partial failures. Incomplete app scans still accept
+ explicit bundle identifiers, previews expire when traffic or policies change, and exceptions appear only after their
+ database write succeeds with a concrete error when it does not.
+
+
+
+
+
0.15.1
Keep managed-pack changes safely in review. Opening or cancelling an off pack no longer
activates it, and configuration changes persist only after an explicit Save succeeds.
Repair managed-pack activity on upgraded databases. Flowlight now verifies the physical
+ activity key during startup and atomically repairs stale schemas, preserving existing rows before any
+ blocked connection is recorded.
+
Keep pack decisions distinct and exactly once. Exact and suffix matches retain separate
+ provenance, repeated decisions aggregate correctly, and retried extension deliveries do not double count.
+
Keep editor feedback truthful under partial failures. Incomplete app scans still accept
+ explicit bundle identifiers, previews expire when traffic or policies change, and exceptions appear only after their
+ database write succeeds with a concrete error when it does not.
+
+
+
+
+
0.15.1
Keep managed-pack changes safely in review. Opening or cancelling an off pack no longer
activates it, and configuration changes persist only after an explicit Save succeeds.