diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 7b3605a..c92f58a 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.4, macOS 15 or later.
+ Everything from the first launch to tuning the agent rules. Flowlight 0.15.5, macOS 15 or later.
On this page
@@ -894,7 +894,7 @@ Understand your AI agents.
brew install --cask xinbetween/tap/flowlightCopy
- v0.15.4macOS 15+UniversalGPL-3.0No telemetry
+ v0.15.5macOS 15+UniversalGPL-3.0No telemetry
connectionslive
@@ -1629,8 +1629,19 @@ Releases
Downloads, checksums and full notes for each version are on GitHub Releases.
+ 0.15.5
+October 22, 2026Latest
+
+ Keep changed packs visible until fresh consent. A catalog update that disables a previously accepted pack now
+ raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
+ returns to the rule list; a fresh receipt-bound activation clears the notice.
+
+ Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
+ main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
+ before replacing it. Push deployments retain their exact pre-push Git baseline.
+
0.15.4
-October 20, 2026Latest
+October 20, 2026
Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional
requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus
diff --git a/docs/llms.txt b/docs/llms.txt
index 6357073..f10ea70 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.4.
+> 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.5.
- [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 2527687..313ec31 100644
--- a/docs/plugins/index.html
+++ b/docs/plugins/index.html
@@ -211,7 +211,7 @@
Keep changed packs visible until fresh consent. A catalog update that disables a previously accepted pack now
+ raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
+ returns to the rule list; a fresh receipt-bound activation clears the notice.
+
Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
+ main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
+ before replacing it. Push deployments retain their exact pre-push Git baseline.
+
+
+
+
+
0.15.4
Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional
requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus
@@ -1295,7 +1307,7 @@
Keep changed packs visible until fresh consent. A catalog update that disables a previously accepted pack now
+ raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
+ returns to the rule list; a fresh receipt-bound activation clears the notice.
+
Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
+ main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
+ before replacing it. Push deployments retain their exact pre-push Git baseline.
+
+
+
+
+
0.15.4
Consume verified official rule-pack updates. Flowlight checks its one fixed HTTPS feed with conditional
requests, verifies publisher signatures and compatibility before persistence, and keeps exact last-known-good bytes plus
From 6758187d63095dcb0590977633517f74d2286780 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Thu, 22 Oct 2026 22:25:15 -0700
Subject: [PATCH 04/13] Keep pack state truthful across restart
Co-Authored-By: Claude Code
---
Flowlight/UI/RulePacksView.swift | 13 +++++--
.../TrafficMonitorRulePackStartupTests.swift | 36 +++++++++++++++++++
docs/llms-full.txt | 4 +++
docs/releases/index.html | 3 ++
project.yml | 2 +-
site/pages/releases.html | 3 ++
6 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift
index b0e5602..613fc55 100644
--- a/Flowlight/UI/RulePacksView.swift
+++ b/Flowlight/UI/RulePacksView.swift
@@ -47,8 +47,17 @@ private struct RulePacksContent: View {
.font(.caption).foregroundStyle(FL.warning)
.padding(10).background(FL.warning.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
}
- ForEach(packs.definitions) { pack in
- card(pack)
+ if catalog.status == .idle {
+ HStack(spacing: 8) {
+ ProgressView().controlSize(.small)
+ Text(L("Loading…")).foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 24)
+ } else {
+ ForEach(packs.definitions) { pack in
+ card(pack)
+ }
}
}
.padding(Measure.gutter)
diff --git a/FlowlightTests/TrafficMonitorRulePackStartupTests.swift b/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
index f60e322..91e50a1 100644
--- a/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
+++ b/FlowlightTests/TrafficMonitorRulePackStartupTests.swift
@@ -34,6 +34,42 @@ final class TrafficMonitorRulePackStartupTests: XCTestCase {
XCTAssertEqual(monitor.rulePackCatalog.receipt?.sequence, verified.sequence)
}
+ @MainActor
+ func testAllEnabledPacksSurviveFreshMonitorStartup() async throws {
+ let url = temporaryDatabaseURL("enabled-restart")
+ let firstSource = RecordingTrafficSource()
+ let first = TrafficMonitor(databaseURL: url, trafficSourceFactory: { firstSource })
+ first.start()
+ try await waitUntil { firstSource.started && first.rulePacks.definitions.count == 3 }
+
+ for definition in first.rulePacks.definitions {
+ var configuration = first.rulePacks.configuration(for: definition.id)
+ configuration.enabled = true
+ var saveResult: RulePackStore.SaveResult?
+ first.rulePacks.save(
+ configuration,
+ reviewedDefinition: definition,
+ receipt: try XCTUnwrap(first.rulePacks.reviewReceipt(for: definition))
+ ) { saveResult = $0 }
+ try await waitUntil { saveResult != nil }
+ XCTAssertEqual(saveResult, .saved)
+ }
+
+ let saved = try TrafficDatabase(url: url).loadRulePackConfigurations()
+ XCTAssertEqual(saved.count, 3)
+ XCTAssertTrue(saved.allSatisfy(\.enabled))
+
+ let restartedSource = RecordingTrafficSource()
+ let restarted = TrafficMonitor(databaseURL: url, trafficSourceFactory: { restartedSource })
+ restarted.start()
+ try await waitUntil { restartedSource.started }
+
+ let expectedIDs = first.rulePacks.definitions.map(\.id)
+ XCTAssertEqual(restarted.rulePacks.configurations.filter(\.enabled).map(\.packID), expectedIDs)
+ XCTAssertEqual(restarted.rulePacks.snapshot?.configurations.map(\.packID), expectedIDs)
+ XCTAssertEqual(restartedSource.rulePackSnapshots.last??.configurations.map(\.packID), expectedIDs)
+ }
+
@MainActor
func testTestModeBootstrapDoesNotInvokeCatalogTransport() async throws {
let source = RecordingTrafficSource()
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index c92f58a..73c4206 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -1636,6 +1636,10 @@ October 22, 2026Latest
raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
returns to the rule list; a fresh receipt-bound activation clears the notice.
+ Show restored pack state truthfully after reopening. Rules › Packs now waits for the signed catalog and saved
+ consent to finish loading instead of briefly drawing every switch as off. Enabled packs, scope and enforcement remain intact
+ across a normal quit and relaunch.
+
Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
before replacing it. Push deployments retain their exact pre-push Git baseline.
diff --git a/docs/releases/index.html b/docs/releases/index.html
index fffd7fe..5ad9658 100644
--- a/docs/releases/index.html
+++ b/docs/releases/index.html
@@ -66,6 +66,9 @@
What's new
Keep changed packs visible until fresh consent. A catalog update that disables a previously accepted pack now
raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
returns to the rule list; a fresh receipt-bound activation clears the notice.
+
Show restored pack state truthfully after reopening. Rules › Packs now waits for the signed catalog and saved
+ consent to finish loading instead of briefly drawing every switch as off. Enabled packs, scope and enforcement remain intact
+ across a normal quit and relaunch.
Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
before replacing it. Push deployments retain their exact pre-push Git baseline.
Keep changed packs visible until fresh consent. A catalog update that disables a previously accepted pack now
raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
returns to the rule list; a fresh receipt-bound activation clears the notice.
+
Show restored pack state truthfully after reopening. Rules › Packs now waits for the signed catalog and saved
+ consent to finish loading instead of briefly drawing every switch as off. Enabled packs, scope and enforcement remain intact
+ across a normal quit and relaunch.
Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
before replacing it. Push deployments retain their exact pre-push Git baseline.
From 4c016feec3c471d5c82927ae262b31393d2e4780 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Thu, 22 Oct 2026 23:37:19 -0700
Subject: [PATCH 05/13] Make pack review state explicit
Co-Authored-By: Claude Code
---
.github/workflows/pages.yml | 30 +++++--
Flowlight/Analysis/RulePackCatalogState.swift | 2 +
Flowlight/Analysis/RulePackStore.swift | 50 ++++++++---
Flowlight/App/AppNavigation.swift | 3 +-
Flowlight/App/DemoData.swift | 1 +
Flowlight/App/TrafficMonitor.swift | 5 +-
.../Localization/de.lproj/Localizable.strings | 2 +
.../Localization/en.lproj/Localizable.strings | 2 +
.../Localization/es.lproj/Localizable.strings | 2 +
.../Localization/fr.lproj/Localizable.strings | 2 +
.../Localization/it.lproj/Localizable.strings | 2 +
.../Localization/ja.lproj/Localizable.strings | 2 +
.../Localization/ko.lproj/Localizable.strings | 2 +
.../pt-PT.lproj/Localizable.strings | 2 +
.../zh-Hans.lproj/Localizable.strings | 2 +
.../zh-Hant.lproj/Localizable.strings | 2 +
Flowlight/Storage/TrafficDatabase.swift | 38 +++++++--
Flowlight/UI/RulePacksView.swift | 37 ++++++++
FlowlightTests/DemoDataTests.swift | 1 +
.../RulePackCatalogPersistenceTests.swift | 1 +
FlowlightTests/RulePackTests.swift | 84 ++++++++++++++++++-
docs/llms-full.txt | 10 +--
docs/releases/index.html | 10 +--
project.yml | 2 +-
scripts/check_site.py | 16 +++-
site/pages/releases.html | 10 +--
26 files changed, 274 insertions(+), 46 deletions(-)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 85a2118..5641878 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -14,6 +14,7 @@ on:
permissions:
contents: read
+ deployments: read
pages: write
id-token: write
@@ -61,14 +62,33 @@ jobs:
--previous-ref "$PREVIOUS_CATALOG_REF"
fi
else
- LIVE_CATALOG=$(mktemp)
- curl --fail --silent --show-error --max-filesize 8388608 \
- --output "$LIVE_CATALOG" "$LIVE_CATALOG_URL"
- python3 -I scripts/rule_pack_catalog.py verify "$LIVE_CATALOG" \
+ DEPLOYED_REF=""
+ DEPLOYMENT_IDS=$(gh api \
+ 'repos/${{ github.repository }}/deployments?environment=github-pages&per_page=20' \
+ --jq '.[].id')
+ for DEPLOYMENT_ID in $DEPLOYMENT_IDS; do
+ DEPLOYMENT_STATE=$(gh api \
+ "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID/statuses" \
+ --jq '.[0].state // empty')
+ if [ "$DEPLOYMENT_STATE" = "success" ]; then
+ DEPLOYED_REF=$(gh api "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID" --jq '.sha')
+ break
+ fi
+ done
+ PREVIOUS_CATALOG=$(mktemp)
+ if [ -n "$DEPLOYED_REF" ]; then
+ gh api -H 'Accept: application/vnd.github.raw+json' \
+ "repos/${{ github.repository }}/contents/site/packs/v1/official.json?ref=$DEPLOYED_REF" \
+ > "$PREVIOUS_CATALOG"
+ else
+ curl --fail --silent --show-error --connect-timeout 10 --max-time 30 --max-filesize 8388608 \
+ --output "$PREVIOUS_CATALOG" "$LIVE_CATALOG_URL"
+ fi
+ python3 -I scripts/rule_pack_catalog.py verify "$PREVIOUS_CATALOG" \
--public-key Flowlight/Resources/RulePacks/official-rule-packs-v1-public.pem \
--expected-key-id flowlight-rule-packs-2026-01
python3 -I scripts/rule_pack_catalog.py compare-previous site/packs/v1/official.json \
- --previous "$LIVE_CATALOG"
+ --previous "$PREVIOUS_CATALOG"
fi
APP_VERSION=$(sed -n 's/.*MARKETING_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml)
test -n "$APP_VERSION"
diff --git a/Flowlight/Analysis/RulePackCatalogState.swift b/Flowlight/Analysis/RulePackCatalogState.swift
index b690d65..e41ffe9 100644
--- a/Flowlight/Analysis/RulePackCatalogState.swift
+++ b/Flowlight/Analysis/RulePackCatalogState.swift
@@ -63,6 +63,8 @@ struct RulePackCatalogAcceptance: Equatable, Sendable {
var state: RulePackCatalogState
/// All saved rows, including dormant IDs omitted by the currently active catalog.
var configurations: [RulePackConfiguration]
+ /// IDs whose enforcement was stopped by a changed or omitted catalog while they were enabled.
+ var pendingReviewPackIDs: Set
/// Durable user state is returned with every accepted catalog so an omitted and later reintroduced pack cannot
/// resume enforcement without the exceptions and history that still belong to its stable ID.
var overrides: [RulePackOverride]
diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift
index 6ca0ae4..86cbf29 100644
--- a/Flowlight/Analysis/RulePackStore.swift
+++ b/Flowlight/Analysis/RulePackStore.swift
@@ -73,6 +73,7 @@ final class RulePackStore: ObservableObject {
private weak var db: TrafficDatabase?
private var definitionsValid = true
private var catalogReceipt: RulePackCatalogReceipt?
+ private var pendingReviewPackIDs = Set()
private var committedSnapshot: RulePackSnapshot?
private var revision = 0
private var activityRevision = 0
@@ -93,7 +94,11 @@ final class RulePackStore: ObservableObject {
var enabledCount: Int { committedSnapshot?.configurations.count ?? 0 }
var hasEnabledPacks: Bool { committedSnapshot != nil }
- var pendingReviewCount: Int { definitions.lazy.filter(needsReview).count }
+ var pendingReviewCount: Int { pendingReviewPackIDs.count }
+ var unavailableReviewConfigurations: [RulePackConfiguration] {
+ let available = Set(definitions.map(\.id))
+ return configurations.filter { pendingReviewPackIDs.contains($0.packID) && !available.contains($0.packID) }
+ }
var failureMessage: String? {
if case .failed(let message) = status { return message }
return nil
@@ -111,8 +116,7 @@ final class RulePackStore: ObservableObject {
}
func needsReview(_ definition: RulePackDefinition) -> Bool {
- guard let configuration = configurations.first(where: { $0.packID == definition.id }) else { return false }
- return configuration.consentedVersion != nil && !configuration.isConsented(to: definition)
+ pendingReviewPackIDs.contains(definition.id)
}
func reviewReceipt(for definition: RulePackDefinition) -> RulePackReviewReceipt? {
@@ -141,11 +145,13 @@ final class RulePackStore: ObservableObject {
let loaded = try await db.asyncResult { db in
(try db.loadRulePackConfigurations(), try db.loadRulePackOverrides(),
try db.rulePackEvents(limit: 300), try db.rulePackDeliveryStats(),
- try db.loadRulePackCatalogState(catalogID: RulePackCatalogVerifier.catalogID).receipt)
+ try db.loadRulePackCatalogState(catalogID: RulePackCatalogVerifier.catalogID).receipt,
+ try db.loadRulePackReviewPackIDs())
}
guard let self else { return }
if self.revision == expectedRevision {
self.catalogReceipt = loaded.4
+ self.pendingReviewPackIDs = loaded.5
self.applyLoaded(configurations: loaded.0, overrides: loaded.1,
events: loaded.2, deliveryStats: loaded.3)
}
@@ -169,6 +175,7 @@ final class RulePackStore: ObservableObject {
definitions = []
definitionsValid = true
catalogReceipt = nil
+ pendingReviewPackIDs = []
configurations = []
overrides = []
committedSnapshot = nil
@@ -196,6 +203,7 @@ final class RulePackStore: ObservableObject {
definitions = catalog.definitions.sorted { $0.id < $1.id }
definitionsValid = true
catalogReceipt = acceptance.state.receipt
+ pendingReviewPackIDs = acceptance.pendingReviewPackIDs
configurations = effectiveConfigurations(from: acceptance.configurations)
let known = Set(definitions.map(\.id))
overrides = acceptance.overrides.filter { known.contains($0.packID) }
@@ -246,7 +254,7 @@ final class RulePackStore: ObservableObject {
if cleaned.scope == .selectedApps && cleaned.apps.isEmpty { cleaned.enabled = false }
var durableReceipt: RulePackReviewReceipt?
- if cleaned.enabled {
+ if cleaned.enabled || pendingReviewPackIDs.contains(cleaned.packID) {
guard let reviewedDefinition, reviewedDefinition == current,
let receipt, receipt.packID == current.id,
receipt.version == current.manifest.version,
@@ -275,6 +283,7 @@ final class RulePackStore: ObservableObject {
self.configurations.append(committed)
self.configurations.sort { $0.packID < $1.packID }
}
+ if committedReceipt != nil { self.pendingReviewPackIDs.remove(committed.packID) }
self.publish()
completion?(.saved)
}, onFailure: { error in
@@ -292,6 +301,22 @@ final class RulePackStore: ObservableObject {
save(value)
}
+ func acknowledgeUnavailableReview(_ packID: String, completion: ((Bool) -> Void)? = nil) {
+ guard pendingReviewPackIDs.contains(packID),
+ configurations.contains(where: { $0.packID == packID }),
+ !definitions.contains(where: { $0.id == packID }) else {
+ completion?(false)
+ return
+ }
+ persist({ try $0.setRulePackReviewRequired(false, packID: packID) }, then: { [weak self] in
+ guard let self else { completion?(false); return }
+ self.revision += 1
+ self.pendingReviewPackIDs.remove(packID)
+ self.publish()
+ completion?(true)
+ }, onFailure: { _ in completion?(false) })
+ }
+
func addOverride(_ override: RulePackOverride, completion: ((Bool) -> Void)? = nil) {
guard definitions.contains(where: { $0.id == override.packID }),
let host = HostCanonicalizer.host(override.host) else {
@@ -400,15 +425,20 @@ final class RulePackStore: ObservableObject {
}
private func effectiveConfigurations(from saved: [RulePackConfiguration]) -> [RulePackConfiguration] {
- 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) {
+ let definitionsByID = Dictionary(uniqueKeysWithValues: definitions.map { ($0.id, $0) })
+ var values = saved.map { savedConfiguration in
+ var configuration = savedConfiguration
+ if configuration.enabled,
+ definitionsByID[configuration.packID].map(configuration.isConsented(to:)) != true {
configuration.enabled = false
+ pendingReviewPackIDs.insert(configuration.packID)
}
return configuration
}
+ for definition in definitions where !values.contains(where: { $0.packID == definition.id }) {
+ values.append(RulePackConfiguration(packID: definition.id))
+ }
+ return values.sorted { $0.packID < $1.packID }
}
private func persist(_ write: @escaping @Sendable (TrafficDatabase) throws -> Void,
diff --git a/Flowlight/App/AppNavigation.swift b/Flowlight/App/AppNavigation.swift
index 0c337cf..fb94f79 100644
--- a/Flowlight/App/AppNavigation.swift
+++ b/Flowlight/App/AppNavigation.swift
@@ -161,8 +161,7 @@ final class AppNavigation: ObservableObject {
}
func showRules() {
- rulesTab = .rules
- selection = .rules
+ show(.rules)
}
func showPackReviews() {
diff --git a/Flowlight/App/DemoData.swift b/Flowlight/App/DemoData.swift
index ec111b9..637c3d5 100644
--- a/Flowlight/App/DemoData.swift
+++ b/Flowlight/App/DemoData.swift
@@ -228,6 +228,7 @@ enum DemoData {
configuration.consentedVersion = "previous-demo-version"
configuration.consentedContentSHA256 = String(repeating: "0", count: 64)
try db.saveRulePackConfiguration(configuration)
+ try db.setRulePackReviewRequired(true, packID: configuration.packID)
}
private static func seedAlerts(_ db: TrafficDatabase, now: Date) throws {
diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift
index f611263..365403b 100644
--- a/Flowlight/App/TrafficMonitor.swift
+++ b/Flowlight/App/TrafficMonitor.swift
@@ -133,7 +133,10 @@ final class TrafficMonitor: ObservableObject {
}
// Unlike the bulk demo history, this opt-in UI fixture has to exist before catalog bootstrap and the pack
// store load race each other. The database is disposable and was reset just above.
- if DemoData.isEnabled && DemoData.showsPackReview { try? DemoData.seedPackReview(db) }
+ if DemoData.isEnabled && DemoData.showsPackReview {
+ do { try DemoData.seedPackReview(db) }
+ catch { lastError = L("Could not prepare managed-pack review demo: %@", error.localizedDescription) }
+ }
readDB = (try? TrafficDatabase(url: db.url, readOnly: true)) ?? db
engine = AnomalyEngine(db: db, activity: activity)
proxyRulePackEvents = RulePackEventDelivery { [weak rulePacks] batch, claim, completion in
diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings
index b4b900a..b02d3e1 100644
--- a/Flowlight/Localization/de.lproj/Localizable.strings
+++ b/Flowlight/Localization/de.lproj/Localizable.strings
@@ -1736,3 +1736,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "Die erfassten Daten konnten nicht gelöscht werden: %@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "Dieses zuvor aktivierte offizielle Paket ist nicht mehr im Katalog. Die Blockierung wurde automatisch beendet.";
+"Could not prepare managed-pack review demo: %@" = "Die Demo zur Überprüfung verwalteter Pakete konnte nicht vorbereitet werden: %@";
diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings
index 6d654e9..b39f416 100644
--- a/Flowlight/Localization/en.lproj/Localizable.strings
+++ b/Flowlight/Localization/en.lproj/Localizable.strings
@@ -1735,3 +1735,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "Couldn't delete the captured data: %@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "This previously enabled official pack is no longer in the catalog. Blocking stopped automatically.";
+"Could not prepare managed-pack review demo: %@" = "Could not prepare managed-pack review demo: %@";
diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings
index b9deeea..1bea9ba 100644
--- a/Flowlight/Localization/es.lproj/Localizable.strings
+++ b/Flowlight/Localization/es.lproj/Localizable.strings
@@ -1736,3 +1736,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "No se pudieron eliminar los datos capturados: %@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "Este paquete oficial, que estaba activado, ya no figura en el catálogo. El bloqueo se detuvo automáticamente.";
+"Could not prepare managed-pack review demo: %@" = "No se pudo preparar la demostración de revisión de paquetes administrados: %@";
diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings
index f3b697a..4ca3e89 100644
--- a/Flowlight/Localization/fr.lproj/Localizable.strings
+++ b/Flowlight/Localization/fr.lproj/Localizable.strings
@@ -1736,3 +1736,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "Impossible de supprimer les données capturées : %@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "Ce pack officiel précédemment activé ne figure plus dans le catalogue. Le blocage s’est arrêté automatiquement.";
+"Could not prepare managed-pack review demo: %@" = "Impossible de préparer la démonstration de vérification des packs gérés : %@";
diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings
index 2996fb8..5e0cfc0 100644
--- a/Flowlight/Localization/it.lproj/Localizable.strings
+++ b/Flowlight/Localization/it.lproj/Localizable.strings
@@ -1736,3 +1736,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "Impossibile eliminare i dati acquisiti: %@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "Questo pacchetto ufficiale precedentemente attivato non è più nel catalogo. Il blocco è stato interrotto automaticamente.";
+"Could not prepare managed-pack review demo: %@" = "Impossibile preparare la demo di revisione dei pacchetti gestiti: %@";
diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings
index 5812f45..9223e31 100644
--- a/Flowlight/Localization/ja.lproj/Localizable.strings
+++ b/Flowlight/Localization/ja.lproj/Localizable.strings
@@ -1736,3 +1736,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "キャプチャしたデータを削除できませんでした:%@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "以前有効にした公式パックはカタログから削除されました。ブロックは自動的に停止しました。";
+"Could not prepare managed-pack review demo: %@" = "管理対象パックのレビューデモを準備できませんでした:%@";
diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings
index 7302968..94e6ff1 100644
--- a/Flowlight/Localization/ko.lproj/Localizable.strings
+++ b/Flowlight/Localization/ko.lproj/Localizable.strings
@@ -1735,3 +1735,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "캡처한 데이터를 삭제할 수 없습니다: %@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "이전에 활성화한 공식 팩이 더 이상 카탈로그에 없습니다. 차단은 자동으로 중지되었습니다.";
+"Could not prepare managed-pack review demo: %@" = "관리형 팩 검토 데모를 준비할 수 없습니다: %@";
diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings
index 7409676..9c90159 100644
--- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings
+++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings
@@ -1735,3 +1735,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "Não foi possível eliminar os dados capturados: %@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "Este pacote oficial anteriormente ativado já não está no catálogo. O bloqueio foi interrompido automaticamente.";
+"Could not prepare managed-pack review demo: %@" = "Não foi possível preparar a demonstração de revisão de pacotes geridos: %@";
diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
index 2429ac2..43e32c2 100644
--- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
+++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
@@ -1735,3 +1735,5 @@
"Suggests reviewed actions" = "待翻译:Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "待翻译:Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "无法删除捕获的数据:%@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "此先前启用的官方包已不在目录中。阻止功能已自动停止。";
+"Could not prepare managed-pack review demo: %@" = "无法准备托管包审查演示:%@";
diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
index 5ef0bf2..55c6af6 100644
--- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
+++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
@@ -1735,3 +1735,5 @@
"Suggests reviewed actions" = "Suggests reviewed actions";
"Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic." = "Plugins are advisory. Installing this package does not let it block, rewrite, mock, transform, save settings, upload, or export traffic.";
"Couldn't delete the captured data: %@" = "無法刪除擷取的資料:%@";
+"This previously enabled official pack is no longer in the catalog. Blocking stopped automatically." = "這個先前啟用的官方套件已不在目錄中。封鎖功能已自動停止。";
+"Could not prepare managed-pack review demo: %@" = "無法準備受管理套件審查示範:%@";
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index d50d173..64550e8 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -486,6 +486,8 @@ final class TrafficDatabase: @unchecked Sendable {
-- 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_reviews (
+ pack_id TEXT PRIMARY KEY, required INTEGER NOT NULL, updated INTEGER NOT NULL);
-- The anti-rollback receipt is deliberately independent from the cached bytes. A corrupt or missing cache
-- must never make a previously accepted sequence disappear and permit the bundled sequence again.
CREATE TABLE IF NOT EXISTS rule_pack_catalog_receipts (
@@ -1089,14 +1091,29 @@ final class TrafficDatabase: @unchecked Sendable {
try writeRulePackConfiguration(configuration, updatedAt: Date())
}
- /// Compare-and-save for an editor that may have remained open across a catalog refresh. Disabling never needs
- /// a receipt; enabling requires both the reviewed pack identity and the durable catalog watermark to still match.
+ func setRulePackReviewRequired(_ required: Bool, packID: String) throws {
+ guard !packID.isEmpty else { throw RulePackCatalogPersistenceError.invalidReceipt }
+ if required {
+ try conn.run("""
+ INSERT INTO rule_pack_reviews (pack_id, required, updated) VALUES (?,1,?)
+ ON CONFLICT(pack_id) DO UPDATE SET required = 1, updated = excluded.updated
+ """, [.text(packID), .int(Int64(Date().timeIntervalSince1970))])
+ } else {
+ try conn.run("DELETE FROM rule_pack_reviews WHERE pack_id = ?", [.text(packID)])
+ }
+ }
+
+ func loadRulePackReviewPackIDs() throws -> Set {
+ Set(try conn.query("SELECT pack_id FROM rule_pack_reviews WHERE required = 1 ORDER BY pack_id") { $0.text(0) })
+ }
+
+ /// Compare-and-save for an editor that may have remained open across a catalog refresh. A receipt is optional only
+ /// for narrowing enforcement. Enabling or acknowledging reviewed content rechecks the durable catalog watermark.
func saveRulePackConfiguration(_ configuration: RulePackConfiguration,
reviewed receipt: RulePackReviewReceipt?) throws {
try conn.transaction {
- if configuration.enabled {
- guard let receipt,
- receipt.packID == configuration.packID,
+ if let receipt {
+ guard receipt.packID == configuration.packID,
receipt.version == configuration.consentedVersion,
receipt.contentSHA256 == configuration.consentedContentSHA256,
let durable = try loadRulePackCatalogReceipt(catalogID: RulePackCatalogVerifier.catalogID),
@@ -1104,8 +1121,11 @@ final class TrafficDatabase: @unchecked Sendable {
durable.digest == receipt.catalogDigest else {
throw RulePackCatalogPersistenceError.staleReview
}
+ } else if configuration.enabled {
+ throw RulePackCatalogPersistenceError.staleReview
}
try writeRulePackConfiguration(configuration, updatedAt: Date())
+ if receipt != nil { try setRulePackReviewRequired(false, packID: configuration.packID) }
}
}
@@ -1121,6 +1141,7 @@ final class TrafficDatabase: @unchecked Sendable {
RulePackCatalogAcceptance(
state: try loadRulePackCatalogState(catalogID: catalogID),
configurations: try loadRulePackConfigurationsStrictly(),
+ pendingReviewPackIDs: try loadRulePackReviewPackIDs(),
overrides: try loadRulePackOverrides(),
events: try rulePackEvents(limit: 300),
deliveryStats: try rulePackDeliveryStats()
@@ -1227,10 +1248,12 @@ final class TrafficDatabase: @unchecked Sendable {
let definitionsByID = Dictionary(uniqueKeysWithValues: catalog.definitions.map { ($0.id, $0) })
let saved = try loadRulePackConfigurationsStrictly()
for var configuration in saved {
- if let definition = definitionsByID[configuration.packID], configuration.enabled,
- !configuration.isConsented(to: definition) {
+ let definition = definitionsByID[configuration.packID]
+ if configuration.enabled,
+ definition.map(configuration.isConsented(to:)) != true {
configuration.enabled = false
try writeRulePackConfiguration(configuration, updatedAt: checkedAt)
+ try setRulePackReviewRequired(true, packID: configuration.packID)
}
reconciled.append(configuration)
}
@@ -1240,6 +1263,7 @@ final class TrafficDatabase: @unchecked Sendable {
return RulePackCatalogAcceptance(
state: state,
configurations: reconciled.sorted { $0.packID < $1.packID },
+ pendingReviewPackIDs: try loadRulePackReviewPackIDs(),
overrides: try loadRulePackOverrides(),
events: try rulePackEvents(limit: 300),
deliveryStats: try rulePackDeliveryStats()
diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift
index 613fc55..1b45d6c 100644
--- a/Flowlight/UI/RulePacksView.swift
+++ b/Flowlight/UI/RulePacksView.swift
@@ -58,6 +58,9 @@ private struct RulePacksContent: View {
ForEach(packs.definitions) { pack in
card(pack)
}
+ ForEach(packs.unavailableReviewConfigurations) { configuration in
+ unavailableCard(configuration)
+ }
}
}
.padding(Measure.gutter)
@@ -220,6 +223,40 @@ private struct RulePacksContent: View {
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 11))
}
+ private func unavailableCard(_ configuration: RulePackConfiguration) -> some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: "shippingbox.and.arrow.backward")
+ .font(.title3).foregroundStyle(FL.warning)
+ .frame(width: 34, height: 34).background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
+ VStack(alignment: .leading, spacing: 3) {
+ Text(configuration.packID).font(.body.weight(.semibold)).textSelection(.enabled)
+ Text(L("This previously enabled official pack is no longer in the catalog. Blocking stopped automatically."))
+ .font(.caption).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ Button(L("Acknowledge")) {
+ saves.begin(configuration.packID)
+ saveErrors[configuration.packID] = nil
+ packs.acknowledgeUnavailableReview(configuration.packID) { success in
+ saves.finish(configuration.packID)
+ if !success {
+ saveErrors[configuration.packID] = packs.failureMessage ?? L("Failed: %@", L("Save"))
+ }
+ }
+ }
+ .disabled(saves.contains(configuration.packID))
+ }
+ if let saveError = saveErrors[configuration.packID] {
+ Label(saveError, systemImage: "exclamationmark.triangle")
+ .font(.caption).foregroundStyle(FL.critical)
+ }
+ }
+ .padding(14)
+ .background(FL.warning.opacity(0.08), in: RoundedRectangle(cornerRadius: 11))
+ }
+
private func status(_ configuration: RulePackConfiguration,
definition: RulePackDefinition) -> String {
if packs.needsReview(definition) { return L("Needs review") }
diff --git a/FlowlightTests/DemoDataTests.swift b/FlowlightTests/DemoDataTests.swift
index 00dc9b5..6d41bac 100644
--- a/FlowlightTests/DemoDataTests.swift
+++ b/FlowlightTests/DemoDataTests.swift
@@ -28,5 +28,6 @@ final class DemoDataTests: XCTestCase {
XCTAssertEqual(saved.packID, BuiltInRulePacks.advertising.id)
XCTAssertFalse(saved.enabled)
XCTAssertFalse(saved.isConsented(to: BuiltInRulePacks.advertising))
+ XCTAssertEqual(try db.loadRulePackReviewPackIDs(), [saved.packID])
}
}
diff --git a/FlowlightTests/RulePackCatalogPersistenceTests.swift b/FlowlightTests/RulePackCatalogPersistenceTests.swift
index 94d26e5..0fe584e 100644
--- a/FlowlightTests/RulePackCatalogPersistenceTests.swift
+++ b/FlowlightTests/RulePackCatalogPersistenceTests.swift
@@ -216,6 +216,7 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
}
configuration.enabled = false
XCTAssertNoThrow(try db.saveRulePackConfiguration(configuration, reviewed: nil))
+ XCTAssertNoThrow(try db.saveRulePackConfiguration(configuration, reviewed: current))
}
func testAsyncResultReturnsValuesAndPropagatesErrors() async throws {
diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift
index 84aa769..26e1205 100644
--- a/FlowlightTests/RulePackTests.swift
+++ b/FlowlightTests/RulePackTests.swift
@@ -824,6 +824,7 @@ final class RulePackTests: XCTestCase {
try await waitUntil { store.configurations.count == 1 && store.reviewReceipt(for: original) != nil }
XCTAssertFalse(store.configuration(for: original.id).enabled)
+ XCTAssertEqual(try db.loadRulePackReviewPackIDs(), [original.id])
XCTAssertTrue(store.needsReview(original))
XCTAssertEqual(store.pendingReviewCount, 1)
XCTAssertNil(store.snapshot)
@@ -858,6 +859,39 @@ final class RulePackTests: XCTestCase {
XCTAssertEqual(store.pendingReviewCount, 0)
}
+ @MainActor
+ func testChangedPackThatWasAlreadyOffDoesNotRequireReview() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-off-before-change-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ let original = BuiltInRulePacks.advertising
+ var configuration = RulePackConfiguration(packID: original.id)
+ configuration.consentedVersion = original.manifest.version
+ configuration.consentedContentSHA256 = original.manifest.contentSHA256
+ try db.saveRulePackConfiguration(configuration)
+
+ var changed = original
+ changed.manifest.version = "2"
+ changed.entries.append(RulePackEntry(id: 999, host: "changed-off.example", category: .advertising))
+ changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" }
+ changed.manifest.entryCount = changed.entries.count
+ changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries)
+ let catalog = VerifiedRulePackCatalog(
+ definitions: [changed], sequence: 2, digest: String(repeating: "2", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("changed-off".utf8)
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ catalog, checkedAt: Date(timeIntervalSince1970: 300),
+ nextEligibleAt: Date(timeIntervalSince1970: 400)
+ )
+ let store = RulePackStore(definitions: [changed])
+ store.attach(db: db)
+ try await waitUntil { store.reviewReceipt(for: changed) != nil }
+
+ XCTAssertFalse(store.needsReview(changed))
+ XCTAssertEqual(store.pendingReviewCount, 0)
+ }
+
func testEditorReviewPreservesOffConfiguration() {
let saved = RulePackConfiguration(packID: "official.advertising")
let draft = RulePackEditorDraft(saved: saved, intent: .review)
@@ -1001,6 +1035,8 @@ final class RulePackTests: XCTestCase {
store.installCommittedCatalog(omitted, acceptance: omissionAcceptance)
XCTAssertTrue(store.overrides.isEmpty)
XCTAssertNil(store.snapshot)
+ XCTAssertEqual(store.pendingReviewCount, 1)
+ XCTAssertEqual(store.unavailableReviewConfigurations.map(\.packID), [definition.id])
let reintroduced = VerifiedRulePackCatalog(
definitions: [definition], sequence: 3, digest: String(repeating: "3", count: 64),
@@ -1013,8 +1049,52 @@ final class RulePackTests: XCTestCase {
store.installCommittedCatalog(reintroduced, acceptance: reintroductionAcceptance)
XCTAssertEqual(store.overrides, [exception])
- XCTAssertEqual(store.snapshot?.overrides, [exception])
- XCTAssertTrue(store.configuration(for: definition.id).enabled)
+ XCTAssertNil(store.snapshot)
+ XCTAssertFalse(store.configuration(for: definition.id).enabled)
+ XCTAssertTrue(store.needsReview(definition))
+ }
+
+ @MainActor
+ func testUnavailablePackReviewCanBeAcknowledgedWithoutReactivation() async throws {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("flowlight-pack-unavailable-ack-\(UUID().uuidString).sqlite")
+ let db = try TrafficDatabase(url: url)
+ var definition = BuiltInRulePacks.advertising
+ definition.manifest.id = "official.removed"
+ let first = VerifiedRulePackCatalog(
+ definitions: [definition], sequence: 1, digest: String(repeating: "1", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("one".utf8)
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ first, checkedAt: Date(timeIntervalSince1970: 100),
+ nextEligibleAt: Date(timeIntervalSince1970: 200)
+ )
+ var configuration = RulePackConfiguration(packID: definition.id, enabled: true)
+ configuration.consentedVersion = definition.manifest.version
+ configuration.consentedContentSHA256 = definition.manifest.contentSHA256
+ try db.saveRulePackConfiguration(configuration)
+ let omitted = VerifiedRulePackCatalog(
+ definitions: [], sequence: 2, digest: String(repeating: "2", count: 64),
+ signerKeyID: "test-key", stale: false, rawEnvelope: Data("two".utf8)
+ )
+ let acceptance = try db.acceptVerifiedRulePackCatalog(
+ omitted, checkedAt: Date(timeIntervalSince1970: 300),
+ nextEligibleAt: Date(timeIntervalSince1970: 400)
+ )
+ let store = RulePackStore(definitions: [], currentAppVersion: "0.15.4")
+ store.attach(db: db)
+ try await waitUntil { store.pendingReviewCount == 1 }
+ XCTAssertEqual(store.installCommittedCatalog(omitted, acceptance: acceptance), .installed)
+
+ var acknowledged: Bool?
+ store.acknowledgeUnavailableReview(definition.id) { acknowledged = $0 }
+ try await waitUntil { acknowledged != nil }
+
+ XCTAssertEqual(acknowledged, true)
+ XCTAssertEqual(store.pendingReviewCount, 0)
+ XCTAssertFalse(store.configuration(for: definition.id).enabled)
+ XCTAssertNil(store.snapshot)
+ XCTAssertTrue(try db.loadRulePackReviewPackIDs().isEmpty)
}
@MainActor
diff --git a/docs/llms-full.txt b/docs/llms-full.txt
index 73c4206..d5947d3 100644
--- a/docs/llms-full.txt
+++ b/docs/llms-full.txt
@@ -1632,17 +1632,17 @@ Releases
0.15.5
October 22, 2026Latest
- Keep changed packs visible until fresh consent. A catalog update that disables a previously accepted pack now
- raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
- returns to the rule list; a fresh receipt-bound activation clears the notice.
+ Keep changed packs visible until fresh consent. A catalog update that disables an active pack now raises a
+ persistent app-wide notice and Rules badge. Changed and removed packs stay off and visible until acknowledged or freshly
+ activated with receipt-bound consent; packs already off do not raise a false warning.
Show restored pack state truthfully after reopening. Rules › Packs now waits for the signed catalog and saved
consent to finish loading instead of briefly drawing every switch as off. Enabled packs, scope and enforcement remain intact
across a normal quit and relaunch.
Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
- main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
- before replacing it. Push deployments retain their exact pre-push Git baseline.
+ main, authenticates the catalog from GitHub’s latest successful Pages deployment (with the live signed feed as a
+ bounded fallback), and rejects rollback or same-sequence equivocation. Pushes retain their exact pre-push Git baseline.
0.15.4
October 20, 2026
diff --git a/docs/releases/index.html b/docs/releases/index.html
index 5ad9658..2af01e6 100644
--- a/docs/releases/index.html
+++ b/docs/releases/index.html
@@ -63,15 +63,15 @@
What's new
0.15.5
Latest
-
Keep changed packs visible until fresh consent. A catalog update that disables a previously accepted pack now
- raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
- returns to the rule list; a fresh receipt-bound activation clears the notice.
+
Keep changed packs visible until fresh consent. A catalog update that disables an active pack now raises a
+ persistent app-wide notice and Rules badge. Changed and removed packs stay off and visible until acknowledged or freshly
+ activated with receipt-bound consent; packs already off do not raise a false warning.
Show restored pack state truthfully after reopening. Rules › Packs now waits for the signed catalog and saved
consent to finish loading instead of briefly drawing every switch as off. Enabled packs, scope and enforcement remain intact
across a normal quit and relaunch.
Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
- main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
- before replacing it. Push deployments retain their exact pre-push Git baseline.
+ main, authenticates the catalog from GitHub’s latest successful Pages deployment (with the live signed feed as a
+ bounded fallback), and rejects rollback or same-sequence equivocation. Pushes retain their exact pre-push Git baseline.
diff --git a/project.yml b/project.yml
index 5ebf857..fd2298d 100644
--- a/project.yml
+++ b/project.yml
@@ -12,7 +12,7 @@ settings:
CODE_SIGN_STYLE: Automatic
ENABLE_HARDENED_RUNTIME: YES
MARKETING_VERSION: "0.15.5"
- CURRENT_PROJECT_VERSION: "43"
+ CURRENT_PROJECT_VERSION: "44"
targets:
Flowlight:
type: application
diff --git a/scripts/check_site.py b/scripts/check_site.py
index 47568e0..acdcb83 100644
--- a/scripts/check_site.py
+++ b/scripts/check_site.py
@@ -373,15 +373,25 @@ def entries(text):
deploy = pages_workflow.split("\n deploy:\n", 1)[1] if "\n deploy:\n" in pages_workflow else ""
catalog_step = deploy.split(" - name: Verify the official rule-pack catalog\n", 1)[1] \
if " - name: Verify the official rule-pack catalog\n" in deploy else ""
+ catalog_step = catalog_step.split("\n - ", 1)[0]
if not re.search(r"(?m)^ workflow_dispatch:\s*$", trigger):
found.append(".github/workflows/pages.yml: manual Pages redeployment is unavailable")
main_only = r"(?m)^ if: \$\{\{ github\.event_name == 'push' \|\| github\.ref == 'refs/heads/main' \}\}\s*$"
if not re.search(main_only, deploy):
found.append(".github/workflows/pages.yml: manual deployment is not restricted to main")
live_url = r"(?m)^ LIVE_CATALOG_URL: https://flowlight\.xinbetween\.com/packs/v1/official\.json\s*$"
- live_compare = r"(?s)curl .*?\"\$LIVE_CATALOG_URL\".*?compare-previous site/packs/v1/official\.json \\\n\s+--previous \"\$LIVE_CATALOG\""
- if not re.search(live_url, catalog_step) or not re.search(live_compare, catalog_step):
- found.append(".github/workflows/pages.yml: manual deployment does not compare with the live signed catalog")
+ deployment_lookup = r"(?s)gh api .*?/deployments\?environment=github-pages&per_page=20'.*?DEPLOYMENT_STATE=.*?/statuses.*?if \[ \"\$DEPLOYMENT_STATE\" = \"success\" \]"
+ deployed_bytes = r"(?s)application/vnd\.github\.raw\+json.*?/contents/site/packs/v1/official\.json\?ref=\$DEPLOYED_REF.*?> \"\$PREVIOUS_CATALOG\""
+ live_fallback = r"(?s)curl .*?--output \"\$PREVIOUS_CATALOG\" \"\$LIVE_CATALOG_URL\""
+ baseline_verify = r"(?s)rule_pack_catalog\.py verify \"\$PREVIOUS_CATALOG\" .*?--expected-key-id flowlight-rule-packs-2026-01"
+ baseline_compare = r"(?s)compare-previous site/packs/v1/official\.json \\\n\s+--previous \"\$PREVIOUS_CATALOG\""
+ if (not re.search(live_url, catalog_step)
+ or not re.search(deployment_lookup, catalog_step)
+ or not re.search(deployed_bytes, catalog_step)
+ or not re.search(live_fallback, catalog_step)
+ or not re.search(baseline_verify, catalog_step)
+ or not re.search(baseline_compare, catalog_step)):
+ found.append(".github/workflows/pages.yml: manual deployment does not verify the recorded or live catalog baseline")
return found
diff --git a/site/pages/releases.html b/site/pages/releases.html
index 855cbd3..bf096b3 100644
--- a/site/pages/releases.html
+++ b/site/pages/releases.html
@@ -15,15 +15,15 @@
What's new
0.15.5
Latest
-
Keep changed packs visible until fresh consent. A catalog update that disables a previously accepted pack now
- raises a persistent app-wide notice and Rules badge. Review opens Rules › Packs directly, while ordinary Rules navigation still
- returns to the rule list; a fresh receipt-bound activation clears the notice.
+
Keep changed packs visible until fresh consent. A catalog update that disables an active pack now raises a
+ persistent app-wide notice and Rules badge. Changed and removed packs stay off and visible until acknowledged or freshly
+ activated with receipt-bound consent; packs already off do not raise a false warning.
Show restored pack state truthfully after reopening. Rules › Packs now waits for the signed catalog and saved
consent to finish loading instead of briefly drawing every switch as off. Enabled packs, scope and enforcement remain intact
across a normal quit and relaunch.
Recover the public site without weakening catalog history. Manual Pages redeployment is restored only for
- main, authenticates the signed catalog already being served, and rejects rollback or same-sequence equivocation
- before replacing it. Push deployments retain their exact pre-push Git baseline.
+ main, authenticates the catalog from GitHub’s latest successful Pages deployment (with the live signed feed as a
+ bounded fallback), and rejects rollback or same-sequence equivocation. Pushes retain their exact pre-push Git baseline.
From 9edfd34efb1f9125dabf3777e47162d52aec2ca5 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Thu, 22 Oct 2026 23:50:04 -0700
Subject: [PATCH 06/13] Authenticate Pages recovery lookups
Co-Authored-By: Claude Code
---
.github/workflows/pages.yml | 2 ++
scripts/check_site.py | 4 +++-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index 5641878..c485870 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -49,6 +49,8 @@ jobs:
# guarantee that nothing broken is ever served.
- name: Verify the official rule-pack catalog
env:
+ # gh does not implicitly read github.token; authenticate deployment-history lookups explicitly.
+ GH_TOKEN: ${{ github.token }}
# Pushes compare with the exact ref main held before the whole push. A manual recovery run instead compares
# with the signed catalog currently served by Pages, so rewritten history cannot roll the public feed back.
PREVIOUS_CATALOG_REF: ${{ github.event.before }}
diff --git a/scripts/check_site.py b/scripts/check_site.py
index acdcb83..0ab9eae 100644
--- a/scripts/check_site.py
+++ b/scripts/check_site.py
@@ -379,13 +379,15 @@ def entries(text):
main_only = r"(?m)^ if: \$\{\{ github\.event_name == 'push' \|\| github\.ref == 'refs/heads/main' \}\}\s*$"
if not re.search(main_only, deploy):
found.append(".github/workflows/pages.yml: manual deployment is not restricted to main")
+ gh_token = r"(?m)^ GH_TOKEN: \$\{\{ github\.token \}\}\s*$"
live_url = r"(?m)^ LIVE_CATALOG_URL: https://flowlight\.xinbetween\.com/packs/v1/official\.json\s*$"
deployment_lookup = r"(?s)gh api .*?/deployments\?environment=github-pages&per_page=20'.*?DEPLOYMENT_STATE=.*?/statuses.*?if \[ \"\$DEPLOYMENT_STATE\" = \"success\" \]"
deployed_bytes = r"(?s)application/vnd\.github\.raw\+json.*?/contents/site/packs/v1/official\.json\?ref=\$DEPLOYED_REF.*?> \"\$PREVIOUS_CATALOG\""
live_fallback = r"(?s)curl .*?--output \"\$PREVIOUS_CATALOG\" \"\$LIVE_CATALOG_URL\""
baseline_verify = r"(?s)rule_pack_catalog\.py verify \"\$PREVIOUS_CATALOG\" .*?--expected-key-id flowlight-rule-packs-2026-01"
baseline_compare = r"(?s)compare-previous site/packs/v1/official\.json \\\n\s+--previous \"\$PREVIOUS_CATALOG\""
- if (not re.search(live_url, catalog_step)
+ if (not re.search(gh_token, catalog_step)
+ or not re.search(live_url, catalog_step)
or not re.search(deployment_lookup, catalog_step)
or not re.search(deployed_bytes, catalog_step)
or not re.search(live_fallback, catalog_step)
From 22c15759256da69a10758a616e7b42ce73af389b Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Thu, 22 Oct 2026 23:50:54 -0700
Subject: [PATCH 07/13] Advance 0.15.5 release build
Co-Authored-By: Claude Code
---
project.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/project.yml b/project.yml
index fd2298d..c0bc0cd 100644
--- a/project.yml
+++ b/project.yml
@@ -12,7 +12,7 @@ settings:
CODE_SIGN_STYLE: Automatic
ENABLE_HARDENED_RUNTIME: YES
MARKETING_VERSION: "0.15.5"
- CURRENT_PROJECT_VERSION: "44"
+ CURRENT_PROJECT_VERSION: "45"
targets:
Flowlight:
type: application
From 736b09a04de8181bada4bb0c0547b87bcd92efcc Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Fri, 23 Oct 2026 00:13:41 -0700
Subject: [PATCH 08/13] Repair legacy pack review state
Co-Authored-By: Claude Code
---
.github/workflows/pages.yml | 40 ++---
.../Analysis/RulePackCatalogUpdater.swift | 8 +-
Flowlight/Analysis/RulePackStore.swift | 18 +--
Flowlight/Storage/TrafficDatabase.swift | 86 +++++++++--
Flowlight/UI/RulesView.swift | 2 +-
.../RulePackCatalogPersistenceTests.swift | 143 ++++++++++++++++++
.../RulePackCatalogUpdaterTests.swift | 5 +
project.yml | 2 +-
scripts/check_site.py | 6 +-
9 files changed, 263 insertions(+), 47 deletions(-)
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index c485870..7ab2104 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -64,25 +64,29 @@ jobs:
--previous-ref "$PREVIOUS_CATALOG_REF"
fi
else
- DEPLOYED_REF=""
- DEPLOYMENT_IDS=$(gh api \
- 'repos/${{ github.repository }}/deployments?environment=github-pages&per_page=20' \
- --jq '.[].id')
- for DEPLOYMENT_ID in $DEPLOYMENT_IDS; do
- DEPLOYMENT_STATE=$(gh api \
- "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID/statuses" \
- --jq '.[0].state // empty')
- if [ "$DEPLOYMENT_STATE" = "success" ]; then
- DEPLOYED_REF=$(gh api "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID" --jq '.sha')
- break
- fi
- done
PREVIOUS_CATALOG=$(mktemp)
- if [ -n "$DEPLOYED_REF" ]; then
- gh api -H 'Accept: application/vnd.github.raw+json' \
- "repos/${{ github.repository }}/contents/site/packs/v1/official.json?ref=$DEPLOYED_REF" \
- > "$PREVIOUS_CATALOG"
- else
+ DEPLOYED_CATALOG_FOUND=false
+ if DEPLOYMENT_IDS=$(gh api \
+ 'repos/${{ github.repository }}/deployments?environment=github-pages&per_page=20' \
+ --jq '.[].id'); then
+ for DEPLOYMENT_ID in $DEPLOYMENT_IDS; do
+ if ! DEPLOYMENT_STATE=$(gh api \
+ "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID/statuses" \
+ --jq '.[0].state // empty'); then
+ continue
+ fi
+ if [ "$DEPLOYMENT_STATE" = "success" ] && \
+ DEPLOYED_REF=$(gh api \
+ "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID" --jq '.sha') && \
+ gh api -H 'Accept: application/vnd.github.raw+json' \
+ "repos/${{ github.repository }}/contents/site/packs/v1/official.json?ref=$DEPLOYED_REF" \
+ > "$PREVIOUS_CATALOG"; then
+ DEPLOYED_CATALOG_FOUND=true
+ break
+ fi
+ done
+ fi
+ if [ "$DEPLOYED_CATALOG_FOUND" != "true" ]; then
curl --fail --silent --show-error --connect-timeout 10 --max-time 30 --max-filesize 8388608 \
--output "$PREVIOUS_CATALOG" "$LIVE_CATALOG_URL"
fi
diff --git a/Flowlight/Analysis/RulePackCatalogUpdater.swift b/Flowlight/Analysis/RulePackCatalogUpdater.swift
index 28eaa1b..56be6ac 100644
--- a/Flowlight/Analysis/RulePackCatalogUpdater.swift
+++ b/Flowlight/Analysis/RulePackCatalogUpdater.swift
@@ -110,7 +110,13 @@ final class RulePackCatalogUpdater: ObservableObject {
allowExpiredLastKnownGood: true
)
let composed = try RulePackCatalogComposition.compose(verified, currentAppVersion: currentAppVersion)
- store.installCommittedCatalog(composed, acceptance: bootstrap)
+ let reconciled = try await db.asyncResult {
+ try $0.reconcileRulePackCatalogBootstrap(
+ bootstrap,
+ definitions: composed.definitions
+ )
+ }
+ store.installCommittedCatalog(composed, acceptance: reconciled)
recoveryNeeded = false
status = verified.stale ? .stale : .current
} catch {
diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift
index 86cbf29..f09e45b 100644
--- a/Flowlight/Analysis/RulePackStore.swift
+++ b/Flowlight/Analysis/RulePackStore.swift
@@ -68,6 +68,7 @@ final class RulePackStore: ObservableObject {
@Published private(set) var status: ApplyStatus = .off
@Published private(set) var version = 0
@Published private(set) var definitions: [RulePackDefinition]
+ @Published private(set) var isRecoveringCatalog = false
var onChange: (RulePackSnapshot?) -> Void = { _ in }
private weak var db: TrafficDatabase?
@@ -94,8 +95,10 @@ final class RulePackStore: ObservableObject {
var enabledCount: Int { committedSnapshot?.configurations.count ?? 0 }
var hasEnabledPacks: Bool { committedSnapshot != nil }
- var pendingReviewCount: Int { pendingReviewPackIDs.count }
+ var hasPackNavigation: Bool { !definitions.isEmpty || isRecoveringCatalog }
+ var pendingReviewCount: Int { isRecoveringCatalog ? 0 : pendingReviewPackIDs.count }
var unavailableReviewConfigurations: [RulePackConfiguration] {
+ guard !isRecoveringCatalog else { return [] }
let available = Set(definitions.map(\.id))
return configurations.filter { pendingReviewPackIDs.contains($0.packID) && !available.contains($0.packID) }
}
@@ -174,6 +177,7 @@ final class RulePackStore: ObservableObject {
revision += 1
definitions = []
definitionsValid = true
+ isRecoveringCatalog = true
catalogReceipt = nil
pendingReviewPackIDs = []
configurations = []
@@ -202,6 +206,7 @@ final class RulePackStore: ObservableObject {
revision += 1
definitions = catalog.definitions.sorted { $0.id < $1.id }
definitionsValid = true
+ isRecoveringCatalog = false
catalogReceipt = acceptance.state.receipt
pendingReviewPackIDs = acceptance.pendingReviewPackIDs
configurations = effectiveConfigurations(from: acceptance.configurations)
@@ -425,16 +430,7 @@ final class RulePackStore: ObservableObject {
}
private func effectiveConfigurations(from saved: [RulePackConfiguration]) -> [RulePackConfiguration] {
- let definitionsByID = Dictionary(uniqueKeysWithValues: definitions.map { ($0.id, $0) })
- var values = saved.map { savedConfiguration in
- var configuration = savedConfiguration
- if configuration.enabled,
- definitionsByID[configuration.packID].map(configuration.isConsented(to:)) != true {
- configuration.enabled = false
- pendingReviewPackIDs.insert(configuration.packID)
- }
- return configuration
- }
+ var values = saved
for definition in definitions where !values.contains(where: { $0.packID == definition.id }) {
values.append(RulePackConfiguration(packID: definition.id))
}
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index 64550e8..b405300 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -1148,6 +1148,32 @@ final class TrafficDatabase: @unchecked Sendable {
)
}
+ /// 0.15.4 disabled changed packs before durable review markers existed and left omitted packs enabled. Once the
+ /// exact accepted catalog has been reverified, repair those rows transactionally before publishing startup state.
+ func reconcileRulePackCatalogBootstrap(
+ _ bootstrap: RulePackCatalogAcceptance,
+ definitions: [RulePackDefinition]
+ ) throws -> RulePackCatalogAcceptance {
+ guard let reviewedAt = bootstrap.state.receipt?.acceptedAt else { return bootstrap }
+ var configurations: [RulePackConfiguration] = []
+ try conn.transaction {
+ configurations = try reconcileRulePackConfigurations(
+ definitions: definitions,
+ legacyReviewAt: reviewedAt,
+ updatedAt: Date(),
+ backfillLegacyDisabled: true
+ )
+ }
+ return RulePackCatalogAcceptance(
+ state: bootstrap.state,
+ configurations: configurations,
+ pendingReviewPackIDs: try loadRulePackReviewPackIDs(),
+ overrides: try loadRulePackOverrides(),
+ events: try rulePackEvents(limit: 300),
+ deliveryStats: try rulePackDeliveryStats()
+ )
+ }
+
func loadRulePackCatalogState(catalogID: String) throws -> RulePackCatalogState {
guard !catalogID.isEmpty else { throw RulePackCatalogPersistenceError.invalidCatalogID }
let receipt = try loadRulePackCatalogReceipt(catalogID: catalogID)
@@ -1182,6 +1208,49 @@ final class TrafficDatabase: @unchecked Sendable {
return RulePackCatalogState(receipt: receipt, cache: cache, update: update)
}
+ private func reconcileRulePackConfigurations(
+ definitions: [RulePackDefinition],
+ legacyReviewAt: Date?,
+ updatedAt: Date,
+ backfillLegacyDisabled: Bool = false
+ ) throws -> [RulePackConfiguration] {
+ let definitionsByID = Dictionary(uniqueKeysWithValues: definitions.map { ($0.id, $0) })
+ let saved = try conn.query(
+ """
+ SELECT configuration, updated,
+ EXISTS(SELECT 1 FROM rule_pack_reviews WHERE rule_pack_reviews.pack_id = rule_packs.pack_id)
+ FROM rule_packs ORDER BY pack_id
+ """
+ ) { ($0.text(0), $0.int(1), $0.int(2) != 0) }
+ var reconciled: [RulePackConfiguration] = []
+ for (json, rowUpdated, reviewAlreadyRequired) in saved {
+ var configuration = try JSONDecoder().decode(
+ RulePackConfiguration.self,
+ from: Data(json.utf8)
+ )
+ let consentIsCurrent = definitionsByID[configuration.packID].map(configuration.isConsented(to:)) == true
+ let wasActive = configuration.enabled
+ let wasStoppedByLegacyAcceptance = backfillLegacyDisabled
+ && !configuration.enabled
+ && !consentIsCurrent
+ && configuration.consentedVersion?.isEmpty == false
+ && !reviewAlreadyRequired
+ && legacyReviewAt.map { rowUpdated == Int64($0.timeIntervalSince1970) } == true
+ if wasActive && !consentIsCurrent {
+ configuration.enabled = false
+ try writeRulePackConfiguration(configuration, updatedAt: updatedAt)
+ } else if wasStoppedByLegacyAcceptance {
+ // Move the row past the legacy acceptance timestamp so an acknowledgment cannot be inferred again.
+ try writeRulePackConfiguration(configuration, updatedAt: updatedAt)
+ }
+ if wasActive && !consentIsCurrent || wasStoppedByLegacyAcceptance {
+ try setRulePackReviewRequired(true, packID: configuration.packID)
+ }
+ reconciled.append(configuration)
+ }
+ return reconciled
+ }
+
/// Atomically rechecks anti-rollback state, saves the exact signed bytes and validators, invalidates stale
/// consent, and resets refresh backoff. Callers may publish the returned definitions/configurations only after
/// this method returns successfully.
@@ -1245,18 +1314,11 @@ final class TrafficDatabase: @unchecked Sendable {
""", [.text(catalogID), .int(acceptedAt), .int(acceptedAt),
.int(Int64(nextEligibleAt.timeIntervalSince1970)), .text("accepted")])
- let definitionsByID = Dictionary(uniqueKeysWithValues: catalog.definitions.map { ($0.id, $0) })
- let saved = try loadRulePackConfigurationsStrictly()
- for var configuration in saved {
- let definition = definitionsByID[configuration.packID]
- if configuration.enabled,
- definition.map(configuration.isConsented(to:)) != true {
- configuration.enabled = false
- try writeRulePackConfiguration(configuration, updatedAt: checkedAt)
- try setRulePackReviewRequired(true, packID: configuration.packID)
- }
- reconciled.append(configuration)
- }
+ reconciled = try reconcileRulePackConfigurations(
+ definitions: catalog.definitions,
+ legacyReviewAt: nil,
+ updatedAt: checkedAt
+ )
}
let state = try loadRulePackCatalogState(catalogID: catalogID)
diff --git a/Flowlight/UI/RulesView.swift b/Flowlight/UI/RulesView.swift
index a525a66..880705a 100644
--- a/Flowlight/UI/RulesView.swift
+++ b/Flowlight/UI/RulesView.swift
@@ -96,7 +96,7 @@ 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 || !monitor.rulePacks.definitions.isEmpty {
+ if !store.rules.isEmpty || !store.events.isEmpty || monitor.rulePacks.hasPackNavigation {
ToolbarItem(placement: .principal) {
Picker("", selection: $nav.rulesTab) {
ForEach(RulesTab.allCases) { Text($0.title).tag($0) }
diff --git a/FlowlightTests/RulePackCatalogPersistenceTests.swift b/FlowlightTests/RulePackCatalogPersistenceTests.swift
index 0fe584e..dda15b5 100644
--- a/FlowlightTests/RulePackCatalogPersistenceTests.swift
+++ b/FlowlightTests/RulePackCatalogPersistenceTests.swift
@@ -142,6 +142,149 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
accepted.configurations)
}
+ func testBootstrapBackfillsReviewForPackDisabledByPreviousRelease() throws {
+ let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-review-backfill"))
+ let original = BuiltInRulePacks.advertising
+ var changed = original
+ changed.manifest.version = "2"
+ changed.entries.append(RulePackEntry(
+ id: 999, host: "legacy-change.example", category: .advertising
+ ))
+ changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" }
+ changed.manifest.entryCount = changed.entries.count
+ changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries)
+ let catalog = verifiedCatalog(
+ sequence: 2,
+ digest: String(repeating: "b", count: 64),
+ definitions: [changed]
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ catalog,
+ checkedAt: acceptedAt,
+ nextEligibleAt: nextCheck
+ )
+ var legacyStopped = RulePackConfiguration(packID: original.id)
+ legacyStopped.consentedVersion = original.manifest.version
+ legacyStopped.consentedContentSHA256 = original.manifest.contentSHA256
+ try db.saveRulePackConfiguration(legacyStopped)
+ _ = try db.raw(
+ "UPDATE rule_packs SET updated = ? WHERE pack_id = ?",
+ [.int(Int64(acceptedAt.timeIntervalSince1970)), .text(original.id)]
+ ) { _ in 0 }
+
+ let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
+ let repaired = try db.reconcileRulePackCatalogBootstrap(
+ bootstrap,
+ definitions: catalog.definitions
+ )
+
+ XCTAssertEqual(repaired.pendingReviewPackIDs, [original.id])
+ XCTAssertFalse(try XCTUnwrap(repaired.configurations.first).enabled)
+ }
+
+ func testBootstrapDoesNotMarkPackUserDisabledAfterCatalogAcceptance() throws {
+ let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-user-disabled"))
+ let original = BuiltInRulePacks.advertising
+ var changed = original
+ changed.manifest.version = "2"
+ changed.entries.append(RulePackEntry(id: 999, host: "changed.example", category: .advertising))
+ changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" }
+ changed.manifest.entryCount = changed.entries.count
+ changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries)
+ let catalog = verifiedCatalog(
+ sequence: 2,
+ digest: String(repeating: "b", count: 64),
+ definitions: [changed]
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ catalog,
+ checkedAt: acceptedAt,
+ nextEligibleAt: nextCheck
+ )
+ var userDisabled = RulePackConfiguration(packID: original.id)
+ userDisabled.consentedVersion = original.manifest.version
+ userDisabled.consentedContentSHA256 = original.manifest.contentSHA256
+ try db.saveRulePackConfiguration(userDisabled)
+
+ let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
+ let repaired = try db.reconcileRulePackCatalogBootstrap(
+ bootstrap,
+ definitions: catalog.definitions
+ )
+
+ XCTAssertTrue(repaired.pendingReviewPackIDs.isEmpty)
+ }
+
+ func testAcknowledgedLegacyBackfillDoesNotReappearOnNextBootstrap() throws {
+ let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-review-ack-stable"))
+ let original = BuiltInRulePacks.advertising
+ var changed = original
+ changed.manifest.version = "2"
+ changed.entries.append(RulePackEntry(id: 999, host: "legacy-change.example", category: .advertising))
+ changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" }
+ changed.manifest.entryCount = changed.entries.count
+ changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries)
+ let catalog = verifiedCatalog(
+ sequence: 2,
+ digest: String(repeating: "b", count: 64),
+ definitions: [changed]
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ catalog,
+ checkedAt: acceptedAt,
+ nextEligibleAt: nextCheck
+ )
+ var legacyStopped = RulePackConfiguration(packID: original.id)
+ legacyStopped.consentedVersion = original.manifest.version
+ legacyStopped.consentedContentSHA256 = original.manifest.contentSHA256
+ try db.saveRulePackConfiguration(legacyStopped)
+ _ = try db.raw(
+ "UPDATE rule_packs SET updated = ? WHERE pack_id = ?",
+ [.int(Int64(acceptedAt.timeIntervalSince1970)), .text(original.id)]
+ ) { _ in 0 }
+ let first = try db.reconcileRulePackCatalogBootstrap(
+ db.loadRulePackCatalogBootstrap(catalogID: catalogID),
+ definitions: catalog.definitions
+ )
+ XCTAssertEqual(first.pendingReviewPackIDs, [original.id])
+
+ try db.setRulePackReviewRequired(false, packID: original.id)
+ let second = try db.reconcileRulePackCatalogBootstrap(
+ db.loadRulePackCatalogBootstrap(catalogID: catalogID),
+ definitions: catalog.definitions
+ )
+
+ XCTAssertTrue(second.pendingReviewPackIDs.isEmpty)
+ }
+
+ func testBootstrapDisablesAndPersistsLegacyOmittedActivePack() throws {
+ let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-omitted-backfill"))
+ let catalog = verifiedCatalog(
+ sequence: 2,
+ digest: String(repeating: "b", count: 64),
+ definitions: []
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ catalog,
+ checkedAt: acceptedAt,
+ nextEligibleAt: nextCheck
+ )
+ var legacyActive = RulePackConfiguration(packID: "official.legacy-omitted", enabled: true)
+ legacyActive.consentedVersion = "1"
+ legacyActive.consentedContentSHA256 = String(repeating: "a", count: 64)
+ try db.saveRulePackConfiguration(legacyActive)
+
+ let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
+ let repaired = try db.reconcileRulePackCatalogBootstrap(
+ bootstrap,
+ definitions: catalog.definitions
+ )
+
+ XCTAssertEqual(repaired.pendingReviewPackIDs, [legacyActive.packID])
+ XCTAssertFalse(try XCTUnwrap(repaired.configurations.first).enabled)
+ XCTAssertFalse(try XCTUnwrap(db.loadRulePackConfigurations().first).enabled)
+ }
+
func testFailedCacheWriteRollsBackReceiptAndConsentReconciliation() throws {
let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-atomic-failure"))
var configuration = RulePackConfiguration(packID: BuiltInRulePacks.advertising.id, enabled: true)
diff --git a/FlowlightTests/RulePackCatalogUpdaterTests.swift b/FlowlightTests/RulePackCatalogUpdaterTests.swift
index 4032e05..98151c0 100644
--- a/FlowlightTests/RulePackCatalogUpdaterTests.swift
+++ b/FlowlightTests/RulePackCatalogUpdaterTests.swift
@@ -158,6 +158,10 @@ final class RulePackCatalogUpdaterTests: XCTestCase {
await updater.bootstrap()
XCTAssertTrue(store.definitions.isEmpty)
+ XCTAssertTrue(store.isRecoveringCatalog)
+ XCTAssertTrue(store.hasPackNavigation)
+ XCTAssertEqual(store.pendingReviewCount, 0)
+ XCTAssertTrue(store.unavailableReviewConfigurations.isEmpty)
XCTAssertNil(store.snapshot)
guard case .recovery = updater.status else { return XCTFail("missing cache must require recovery") }
updater.startScheduling(networkingEnabled: true)
@@ -168,6 +172,7 @@ final class RulePackCatalogUpdaterTests: XCTestCase {
XCTAssertEqual(updater.receipt?.sequence, 2)
XCTAssertEqual(updater.status, .current)
XCTAssertFalse(store.definitions.isEmpty)
+ XCTAssertFalse(store.isRecoveringCatalog)
}
@MainActor
diff --git a/project.yml b/project.yml
index c0bc0cd..fd5234e 100644
--- a/project.yml
+++ b/project.yml
@@ -12,7 +12,7 @@ settings:
CODE_SIGN_STYLE: Automatic
ENABLE_HARDENED_RUNTIME: YES
MARKETING_VERSION: "0.15.5"
- CURRENT_PROJECT_VERSION: "45"
+ CURRENT_PROJECT_VERSION: "46"
targets:
Flowlight:
type: application
diff --git a/scripts/check_site.py b/scripts/check_site.py
index 0ab9eae..41ce6fd 100644
--- a/scripts/check_site.py
+++ b/scripts/check_site.py
@@ -381,9 +381,9 @@ def entries(text):
found.append(".github/workflows/pages.yml: manual deployment is not restricted to main")
gh_token = r"(?m)^ GH_TOKEN: \$\{\{ github\.token \}\}\s*$"
live_url = r"(?m)^ LIVE_CATALOG_URL: https://flowlight\.xinbetween\.com/packs/v1/official\.json\s*$"
- deployment_lookup = r"(?s)gh api .*?/deployments\?environment=github-pages&per_page=20'.*?DEPLOYMENT_STATE=.*?/statuses.*?if \[ \"\$DEPLOYMENT_STATE\" = \"success\" \]"
- deployed_bytes = r"(?s)application/vnd\.github\.raw\+json.*?/contents/site/packs/v1/official\.json\?ref=\$DEPLOYED_REF.*?> \"\$PREVIOUS_CATALOG\""
- live_fallback = r"(?s)curl .*?--output \"\$PREVIOUS_CATALOG\" \"\$LIVE_CATALOG_URL\""
+ deployment_lookup = r"(?s)if DEPLOYMENT_IDS=\$\(gh api .*?/deployments\?environment=github-pages&per_page=20'.*?DEPLOYMENT_STATE=\$\(gh api .*?/statuses.*?continue"
+ deployed_bytes = r"(?s)DEPLOYED_REF=\$\(gh api .*?application/vnd\.github\.raw\+json.*?/contents/site/packs/v1/official\.json\?ref=\$DEPLOYED_REF.*?> \"\$PREVIOUS_CATALOG\".*?DEPLOYED_CATALOG_FOUND=true"
+ live_fallback = r"(?s)if \[ \"\$DEPLOYED_CATALOG_FOUND\" != \"true\" \].*?curl .*?--output \"\$PREVIOUS_CATALOG\" \"\$LIVE_CATALOG_URL\""
baseline_verify = r"(?s)rule_pack_catalog\.py verify \"\$PREVIOUS_CATALOG\" .*?--expected-key-id flowlight-rule-packs-2026-01"
baseline_compare = r"(?s)compare-previous site/packs/v1/official\.json \\\n\s+--previous \"\$PREVIOUS_CATALOG\""
if (not re.search(gh_token, catalog_step)
From 12734b0d6ab6e8ef4c9df93c709034803ed89452 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Fri, 23 Oct 2026 00:18:55 -0700
Subject: [PATCH 09/13] Preserve catalog migration evidence
Co-Authored-By: Claude Code
---
Flowlight/Storage/TrafficDatabase.swift | 7 ++++++-
FlowlightTests/RulePackCatalogPersistenceTests.swift | 7 +++++++
project.yml | 2 +-
3 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index b405300..83ac944 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -1290,7 +1290,12 @@ final class TrafficDatabase: @unchecked Sendable {
highest_sequence = excluded.highest_sequence,
canonical_digest = excluded.canonical_digest,
signer_key_id = excluded.signer_key_id,
- accepted_at = excluded.accepted_at
+ accepted_at = CASE
+ WHEN rule_pack_catalog_receipts.highest_sequence = excluded.highest_sequence
+ AND rule_pack_catalog_receipts.canonical_digest = excluded.canonical_digest
+ THEN rule_pack_catalog_receipts.accepted_at
+ ELSE excluded.accepted_at
+ END
""", [.text(catalogID), .int(catalog.sequence), .text(catalog.digest),
.text(catalog.signerKeyID), .int(acceptedAt)])
try conn.run("""
diff --git a/FlowlightTests/RulePackCatalogPersistenceTests.swift b/FlowlightTests/RulePackCatalogPersistenceTests.swift
index dda15b5..ede3dc5 100644
--- a/FlowlightTests/RulePackCatalogPersistenceTests.swift
+++ b/FlowlightTests/RulePackCatalogPersistenceTests.swift
@@ -103,6 +103,8 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
let state = try db.loadRulePackCatalogState(catalogID: catalogID)
XCTAssertEqual(state.receipt?.sequence, 2)
+ XCTAssertEqual(state.receipt?.acceptedAt, acceptedAt,
+ "reaccepting identical content must preserve migration evidence")
XCTAssertEqual(state.cache?.rawEnvelope, recovered.rawEnvelope)
XCTAssertEqual(state.cache?.etag, "new")
}
@@ -171,6 +173,11 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
"UPDATE rule_packs SET updated = ? WHERE pack_id = ?",
[.int(Int64(acceptedAt.timeIntervalSince1970)), .text(original.id)]
) { _ in 0 }
+ _ = try db.acceptVerifiedRulePackCatalog(
+ catalog,
+ checkedAt: acceptedAt.addingTimeInterval(10),
+ nextEligibleAt: nextCheck.addingTimeInterval(10)
+ )
let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
let repaired = try db.reconcileRulePackCatalogBootstrap(
diff --git a/project.yml b/project.yml
index fd5234e..c7ce411 100644
--- a/project.yml
+++ b/project.yml
@@ -12,7 +12,7 @@ settings:
CODE_SIGN_STYLE: Automatic
ENABLE_HARDENED_RUNTIME: YES
MARKETING_VERSION: "0.15.5"
- CURRENT_PROJECT_VERSION: "46"
+ CURRENT_PROJECT_VERSION: "47"
targets:
Flowlight:
type: application
From 25a7cb56f30c40788875048eee5eda31032afbe0 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Fri, 23 Oct 2026 00:24:04 -0700
Subject: [PATCH 10/13] Backfill legacy catalog review markers
Co-Authored-By: Claude Code
---
.../Analysis/RulePackCatalogUpdater.swift | 3 +-
.../Analysis/RulePackCatalogVerifier.swift | 2 +
Flowlight/Storage/TrafficDatabase.swift | 15 +++--
.../RulePackCatalogPersistenceTests.swift | 63 ++++++++++++++++---
project.yml | 2 +-
5 files changed, 67 insertions(+), 18 deletions(-)
diff --git a/Flowlight/Analysis/RulePackCatalogUpdater.swift b/Flowlight/Analysis/RulePackCatalogUpdater.swift
index 56be6ac..43041b0 100644
--- a/Flowlight/Analysis/RulePackCatalogUpdater.swift
+++ b/Flowlight/Analysis/RulePackCatalogUpdater.swift
@@ -113,7 +113,8 @@ final class RulePackCatalogUpdater: ObservableObject {
let reconciled = try await db.asyncResult {
try $0.reconcileRulePackCatalogBootstrap(
bootstrap,
- definitions: composed.definitions
+ definitions: composed.definitions,
+ catalogIssuedAt: Date(timeIntervalSince1970: TimeInterval(composed.issuedAt))
)
}
store.installCommittedCatalog(composed, acceptance: reconciled)
diff --git a/Flowlight/Analysis/RulePackCatalogVerifier.swift b/Flowlight/Analysis/RulePackCatalogVerifier.swift
index 185eff6..f842423 100644
--- a/Flowlight/Analysis/RulePackCatalogVerifier.swift
+++ b/Flowlight/Analysis/RulePackCatalogVerifier.swift
@@ -82,6 +82,7 @@ struct RulePackCatalogEnvelope: Codable, Equatable, Sendable {
struct VerifiedRulePackCatalog: Equatable, Sendable {
var definitions: [RulePackDefinition]
var sequence: Int64
+ var issuedAt: Int64 = 0
var digest: String
var signerKeyID: String
var stale: Bool
@@ -213,6 +214,7 @@ enum RulePackCatalogVerifier {
return VerifiedRulePackCatalog(
definitions: definitions,
sequence: envelope.payload.sequence,
+ issuedAt: envelope.payload.issuedAt,
digest: digest,
signerKeyID: signerKeyID,
stale: stale,
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index 83ac944..673b24a 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -1152,14 +1152,15 @@ final class TrafficDatabase: @unchecked Sendable {
/// exact accepted catalog has been reverified, repair those rows transactionally before publishing startup state.
func reconcileRulePackCatalogBootstrap(
_ bootstrap: RulePackCatalogAcceptance,
- definitions: [RulePackDefinition]
+ definitions: [RulePackDefinition],
+ catalogIssuedAt: Date
) throws -> RulePackCatalogAcceptance {
- guard let reviewedAt = bootstrap.state.receipt?.acceptedAt else { return bootstrap }
+ guard let catalogAcceptedAt = bootstrap.state.receipt?.acceptedAt else { return bootstrap }
var configurations: [RulePackConfiguration] = []
try conn.transaction {
configurations = try reconcileRulePackConfigurations(
definitions: definitions,
- legacyReviewAt: reviewedAt,
+ legacyCatalogWindow: catalogIssuedAt...catalogAcceptedAt,
updatedAt: Date(),
backfillLegacyDisabled: true
)
@@ -1210,7 +1211,7 @@ final class TrafficDatabase: @unchecked Sendable {
private func reconcileRulePackConfigurations(
definitions: [RulePackDefinition],
- legacyReviewAt: Date?,
+ legacyCatalogWindow: ClosedRange?,
updatedAt: Date,
backfillLegacyDisabled: Bool = false
) throws -> [RulePackConfiguration] {
@@ -1235,7 +1236,9 @@ final class TrafficDatabase: @unchecked Sendable {
&& !consentIsCurrent
&& configuration.consentedVersion?.isEmpty == false
&& !reviewAlreadyRequired
- && legacyReviewAt.map { rowUpdated == Int64($0.timeIntervalSince1970) } == true
+ && legacyCatalogWindow.map {
+ $0.contains(Date(timeIntervalSince1970: TimeInterval(rowUpdated)))
+ } == true
if wasActive && !consentIsCurrent {
configuration.enabled = false
try writeRulePackConfiguration(configuration, updatedAt: updatedAt)
@@ -1321,7 +1324,7 @@ final class TrafficDatabase: @unchecked Sendable {
reconciled = try reconcileRulePackConfigurations(
definitions: catalog.definitions,
- legacyReviewAt: nil,
+ legacyCatalogWindow: nil,
updatedAt: checkedAt
)
}
diff --git a/FlowlightTests/RulePackCatalogPersistenceTests.swift b/FlowlightTests/RulePackCatalogPersistenceTests.swift
index ede3dc5..30a7d49 100644
--- a/FlowlightTests/RulePackCatalogPersistenceTests.swift
+++ b/FlowlightTests/RulePackCatalogPersistenceTests.swift
@@ -173,16 +173,18 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
"UPDATE rule_packs SET updated = ? WHERE pack_id = ?",
[.int(Int64(acceptedAt.timeIntervalSince1970)), .text(original.id)]
) { _ in 0 }
- _ = try db.acceptVerifiedRulePackCatalog(
- catalog,
- checkedAt: acceptedAt.addingTimeInterval(10),
- nextEligibleAt: nextCheck.addingTimeInterval(10)
- )
+ // Reproduce a database already written by 0.15.4: a later identical 200 moved the receipt timestamp to T2,
+ // while the row disabled at T1 retained its original update time and there was no durable review table entry.
+ _ = try db.raw(
+ "UPDATE rule_pack_catalog_receipts SET accepted_at = ? WHERE catalog_id = ?",
+ [.int(Int64(acceptedAt.addingTimeInterval(10).timeIntervalSince1970)), .text(catalogID)]
+ ) { _ in 0 }
let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
let repaired = try db.reconcileRulePackCatalogBootstrap(
bootstrap,
- definitions: catalog.definitions
+ definitions: catalog.definitions,
+ catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
)
XCTAssertEqual(repaired.pendingReviewPackIDs, [original.id])
@@ -216,7 +218,45 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
let repaired = try db.reconcileRulePackCatalogBootstrap(
bootstrap,
- definitions: catalog.definitions
+ definitions: catalog.definitions,
+ catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
+ )
+
+ XCTAssertTrue(repaired.pendingReviewPackIDs.isEmpty)
+ }
+
+ func testBootstrapDoesNotMarkPackUserDisabledBeforeChangedCatalogWasIssued() throws {
+ let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-disabled-before-issue"))
+ let original = BuiltInRulePacks.advertising
+ var changed = original
+ changed.manifest.version = "2"
+ changed.entries.append(RulePackEntry(id: 999, host: "changed.example", category: .advertising))
+ changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" }
+ changed.manifest.entryCount = changed.entries.count
+ changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries)
+ let catalog = verifiedCatalog(
+ sequence: 2,
+ digest: String(repeating: "b", count: 64),
+ definitions: [changed]
+ )
+ _ = try db.acceptVerifiedRulePackCatalog(
+ catalog,
+ checkedAt: acceptedAt,
+ nextEligibleAt: nextCheck
+ )
+ var userDisabled = RulePackConfiguration(packID: original.id)
+ userDisabled.consentedVersion = original.manifest.version
+ userDisabled.consentedContentSHA256 = original.manifest.contentSHA256
+ try db.saveRulePackConfiguration(userDisabled)
+ _ = try db.raw(
+ "UPDATE rule_packs SET updated = ? WHERE pack_id = ?",
+ [.int(Int64(acceptedAt.addingTimeInterval(-200).timeIntervalSince1970)), .text(original.id)]
+ ) { _ in 0 }
+
+ let repaired = try db.reconcileRulePackCatalogBootstrap(
+ db.loadRulePackCatalogBootstrap(catalogID: catalogID),
+ definitions: catalog.definitions,
+ catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
)
XCTAssertTrue(repaired.pendingReviewPackIDs.isEmpty)
@@ -251,14 +291,16 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
) { _ in 0 }
let first = try db.reconcileRulePackCatalogBootstrap(
db.loadRulePackCatalogBootstrap(catalogID: catalogID),
- definitions: catalog.definitions
+ definitions: catalog.definitions,
+ catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
)
XCTAssertEqual(first.pendingReviewPackIDs, [original.id])
try db.setRulePackReviewRequired(false, packID: original.id)
let second = try db.reconcileRulePackCatalogBootstrap(
db.loadRulePackCatalogBootstrap(catalogID: catalogID),
- definitions: catalog.definitions
+ definitions: catalog.definitions,
+ catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
)
XCTAssertTrue(second.pendingReviewPackIDs.isEmpty)
@@ -284,7 +326,8 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
let repaired = try db.reconcileRulePackCatalogBootstrap(
bootstrap,
- definitions: catalog.definitions
+ definitions: catalog.definitions,
+ catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
)
XCTAssertEqual(repaired.pendingReviewPackIDs, [legacyActive.packID])
diff --git a/project.yml b/project.yml
index c7ce411..559efbb 100644
--- a/project.yml
+++ b/project.yml
@@ -12,7 +12,7 @@ settings:
CODE_SIGN_STYLE: Automatic
ENABLE_HARDENED_RUNTIME: YES
MARKETING_VERSION: "0.15.5"
- CURRENT_PROJECT_VERSION: "47"
+ CURRENT_PROJECT_VERSION: "48"
targets:
Flowlight:
type: application
From a0edc17c8a065a903705a9ff08fff4d7ef05fbfc Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Fri, 23 Oct 2026 00:57:21 -0700
Subject: [PATCH 11/13] Avoid ambiguous legacy review inference
Co-Authored-By: Claude Code
---
.../Analysis/RulePackCatalogUpdater.swift | 3 +-
.../Analysis/RulePackCatalogVerifier.swift | 2 -
Flowlight/Storage/TrafficDatabase.swift | 45 ++----
.../RulePackCatalogPersistenceTests.swift | 148 ++----------------
4 files changed, 21 insertions(+), 177 deletions(-)
diff --git a/Flowlight/Analysis/RulePackCatalogUpdater.swift b/Flowlight/Analysis/RulePackCatalogUpdater.swift
index 43041b0..56be6ac 100644
--- a/Flowlight/Analysis/RulePackCatalogUpdater.swift
+++ b/Flowlight/Analysis/RulePackCatalogUpdater.swift
@@ -113,8 +113,7 @@ final class RulePackCatalogUpdater: ObservableObject {
let reconciled = try await db.asyncResult {
try $0.reconcileRulePackCatalogBootstrap(
bootstrap,
- definitions: composed.definitions,
- catalogIssuedAt: Date(timeIntervalSince1970: TimeInterval(composed.issuedAt))
+ definitions: composed.definitions
)
}
store.installCommittedCatalog(composed, acceptance: reconciled)
diff --git a/Flowlight/Analysis/RulePackCatalogVerifier.swift b/Flowlight/Analysis/RulePackCatalogVerifier.swift
index f842423..185eff6 100644
--- a/Flowlight/Analysis/RulePackCatalogVerifier.swift
+++ b/Flowlight/Analysis/RulePackCatalogVerifier.swift
@@ -82,7 +82,6 @@ struct RulePackCatalogEnvelope: Codable, Equatable, Sendable {
struct VerifiedRulePackCatalog: Equatable, Sendable {
var definitions: [RulePackDefinition]
var sequence: Int64
- var issuedAt: Int64 = 0
var digest: String
var signerKeyID: String
var stale: Bool
@@ -214,7 +213,6 @@ enum RulePackCatalogVerifier {
return VerifiedRulePackCatalog(
definitions: definitions,
sequence: envelope.payload.sequence,
- issuedAt: envelope.payload.issuedAt,
digest: digest,
signerKeyID: signerKeyID,
stale: stale,
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index 673b24a..d0e5480 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -1148,21 +1148,19 @@ final class TrafficDatabase: @unchecked Sendable {
)
}
- /// 0.15.4 disabled changed packs before durable review markers existed and left omitted packs enabled. Once the
- /// exact accepted catalog has been reverified, repair those rows transactionally before publishing startup state.
+ /// 0.15.4 could leave an omitted downloaded pack enabled because missing definitions were not reconciled. Once
+ /// the exact accepted catalog has been reverified, durably stop that unambiguous legacy state before publication.
+ /// Disabled stale rows are deliberately left alone: older storage has no provenance that distinguishes a catalog
+ /// invalidation from a user turning the pack off before that catalog reached this Mac.
func reconcileRulePackCatalogBootstrap(
_ bootstrap: RulePackCatalogAcceptance,
- definitions: [RulePackDefinition],
- catalogIssuedAt: Date
+ definitions: [RulePackDefinition]
) throws -> RulePackCatalogAcceptance {
- guard let catalogAcceptedAt = bootstrap.state.receipt?.acceptedAt else { return bootstrap }
var configurations: [RulePackConfiguration] = []
try conn.transaction {
configurations = try reconcileRulePackConfigurations(
definitions: definitions,
- legacyCatalogWindow: catalogIssuedAt...catalogAcceptedAt,
- updatedAt: Date(),
- backfillLegacyDisabled: true
+ updatedAt: Date()
)
}
return RulePackCatalogAcceptance(
@@ -1211,42 +1209,22 @@ final class TrafficDatabase: @unchecked Sendable {
private func reconcileRulePackConfigurations(
definitions: [RulePackDefinition],
- legacyCatalogWindow: ClosedRange?,
- updatedAt: Date,
- backfillLegacyDisabled: Bool = false
+ updatedAt: Date
) throws -> [RulePackConfiguration] {
let definitionsByID = Dictionary(uniqueKeysWithValues: definitions.map { ($0.id, $0) })
let saved = try conn.query(
- """
- SELECT configuration, updated,
- EXISTS(SELECT 1 FROM rule_pack_reviews WHERE rule_pack_reviews.pack_id = rule_packs.pack_id)
- FROM rule_packs ORDER BY pack_id
- """
- ) { ($0.text(0), $0.int(1), $0.int(2) != 0) }
+ "SELECT configuration FROM rule_packs ORDER BY pack_id"
+ ) { $0.text(0) }
var reconciled: [RulePackConfiguration] = []
- for (json, rowUpdated, reviewAlreadyRequired) in saved {
+ for json in saved {
var configuration = try JSONDecoder().decode(
RulePackConfiguration.self,
from: Data(json.utf8)
)
let consentIsCurrent = definitionsByID[configuration.packID].map(configuration.isConsented(to:)) == true
- let wasActive = configuration.enabled
- let wasStoppedByLegacyAcceptance = backfillLegacyDisabled
- && !configuration.enabled
- && !consentIsCurrent
- && configuration.consentedVersion?.isEmpty == false
- && !reviewAlreadyRequired
- && legacyCatalogWindow.map {
- $0.contains(Date(timeIntervalSince1970: TimeInterval(rowUpdated)))
- } == true
- if wasActive && !consentIsCurrent {
+ if configuration.enabled && !consentIsCurrent {
configuration.enabled = false
try writeRulePackConfiguration(configuration, updatedAt: updatedAt)
- } else if wasStoppedByLegacyAcceptance {
- // Move the row past the legacy acceptance timestamp so an acknowledgment cannot be inferred again.
- try writeRulePackConfiguration(configuration, updatedAt: updatedAt)
- }
- if wasActive && !consentIsCurrent || wasStoppedByLegacyAcceptance {
try setRulePackReviewRequired(true, packID: configuration.packID)
}
reconciled.append(configuration)
@@ -1324,7 +1302,6 @@ final class TrafficDatabase: @unchecked Sendable {
reconciled = try reconcileRulePackConfigurations(
definitions: catalog.definitions,
- legacyCatalogWindow: nil,
updatedAt: checkedAt
)
}
diff --git a/FlowlightTests/RulePackCatalogPersistenceTests.swift b/FlowlightTests/RulePackCatalogPersistenceTests.swift
index 30a7d49..adc9129 100644
--- a/FlowlightTests/RulePackCatalogPersistenceTests.swift
+++ b/FlowlightTests/RulePackCatalogPersistenceTests.swift
@@ -144,55 +144,8 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
accepted.configurations)
}
- func testBootstrapBackfillsReviewForPackDisabledByPreviousRelease() throws {
- let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-review-backfill"))
- let original = BuiltInRulePacks.advertising
- var changed = original
- changed.manifest.version = "2"
- changed.entries.append(RulePackEntry(
- id: 999, host: "legacy-change.example", category: .advertising
- ))
- changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" }
- changed.manifest.entryCount = changed.entries.count
- changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries)
- let catalog = verifiedCatalog(
- sequence: 2,
- digest: String(repeating: "b", count: 64),
- definitions: [changed]
- )
- _ = try db.acceptVerifiedRulePackCatalog(
- catalog,
- checkedAt: acceptedAt,
- nextEligibleAt: nextCheck
- )
- var legacyStopped = RulePackConfiguration(packID: original.id)
- legacyStopped.consentedVersion = original.manifest.version
- legacyStopped.consentedContentSHA256 = original.manifest.contentSHA256
- try db.saveRulePackConfiguration(legacyStopped)
- _ = try db.raw(
- "UPDATE rule_packs SET updated = ? WHERE pack_id = ?",
- [.int(Int64(acceptedAt.timeIntervalSince1970)), .text(original.id)]
- ) { _ in 0 }
- // Reproduce a database already written by 0.15.4: a later identical 200 moved the receipt timestamp to T2,
- // while the row disabled at T1 retained its original update time and there was no durable review table entry.
- _ = try db.raw(
- "UPDATE rule_pack_catalog_receipts SET accepted_at = ? WHERE catalog_id = ?",
- [.int(Int64(acceptedAt.addingTimeInterval(10).timeIntervalSince1970)), .text(catalogID)]
- ) { _ in 0 }
-
- let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
- let repaired = try db.reconcileRulePackCatalogBootstrap(
- bootstrap,
- definitions: catalog.definitions,
- catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
- )
-
- XCTAssertEqual(repaired.pendingReviewPackIDs, [original.id])
- XCTAssertFalse(try XCTUnwrap(repaired.configurations.first).enabled)
- }
-
- func testBootstrapDoesNotMarkPackUserDisabledAfterCatalogAcceptance() throws {
- let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-user-disabled"))
+ func testBootstrapDoesNotInferReviewForLegacyDisabledStalePack() throws {
+ let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-no-disabled-inference"))
let original = BuiltInRulePacks.advertising
var changed = original
changed.manifest.version = "2"
@@ -210,100 +163,18 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
checkedAt: acceptedAt,
nextEligibleAt: nextCheck
)
- var userDisabled = RulePackConfiguration(packID: original.id)
- userDisabled.consentedVersion = original.manifest.version
- userDisabled.consentedContentSHA256 = original.manifest.contentSHA256
- try db.saveRulePackConfiguration(userDisabled)
-
- let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
- let repaired = try db.reconcileRulePackCatalogBootstrap(
- bootstrap,
- definitions: catalog.definitions,
- catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
- )
-
- XCTAssertTrue(repaired.pendingReviewPackIDs.isEmpty)
- }
-
- func testBootstrapDoesNotMarkPackUserDisabledBeforeChangedCatalogWasIssued() throws {
- let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-disabled-before-issue"))
- let original = BuiltInRulePacks.advertising
- var changed = original
- changed.manifest.version = "2"
- changed.entries.append(RulePackEntry(id: 999, host: "changed.example", category: .advertising))
- changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" }
- changed.manifest.entryCount = changed.entries.count
- changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries)
- let catalog = verifiedCatalog(
- sequence: 2,
- digest: String(repeating: "b", count: 64),
- definitions: [changed]
- )
- _ = try db.acceptVerifiedRulePackCatalog(
- catalog,
- checkedAt: acceptedAt,
- nextEligibleAt: nextCheck
- )
- var userDisabled = RulePackConfiguration(packID: original.id)
- userDisabled.consentedVersion = original.manifest.version
- userDisabled.consentedContentSHA256 = original.manifest.contentSHA256
- try db.saveRulePackConfiguration(userDisabled)
- _ = try db.raw(
- "UPDATE rule_packs SET updated = ? WHERE pack_id = ?",
- [.int(Int64(acceptedAt.addingTimeInterval(-200).timeIntervalSince1970)), .text(original.id)]
- ) { _ in 0 }
+ var disabled = RulePackConfiguration(packID: original.id)
+ disabled.consentedVersion = original.manifest.version
+ disabled.consentedContentSHA256 = original.manifest.contentSHA256
+ try db.saveRulePackConfiguration(disabled)
let repaired = try db.reconcileRulePackCatalogBootstrap(
db.loadRulePackCatalogBootstrap(catalogID: catalogID),
- definitions: catalog.definitions,
- catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
+ definitions: catalog.definitions
)
XCTAssertTrue(repaired.pendingReviewPackIDs.isEmpty)
- }
-
- func testAcknowledgedLegacyBackfillDoesNotReappearOnNextBootstrap() throws {
- let db = try TrafficDatabase(url: temporaryDatabaseURL("catalog-review-ack-stable"))
- let original = BuiltInRulePacks.advertising
- var changed = original
- changed.manifest.version = "2"
- changed.entries.append(RulePackEntry(id: 999, host: "legacy-change.example", category: .advertising))
- changed.entries.sort { "\($0.host)|\($0.match.rawValue)" < "\($1.host)|\($1.match.rawValue)" }
- changed.manifest.entryCount = changed.entries.count
- changed.manifest.contentSHA256 = RulePackValidator.digest(changed.entries)
- let catalog = verifiedCatalog(
- sequence: 2,
- digest: String(repeating: "b", count: 64),
- definitions: [changed]
- )
- _ = try db.acceptVerifiedRulePackCatalog(
- catalog,
- checkedAt: acceptedAt,
- nextEligibleAt: nextCheck
- )
- var legacyStopped = RulePackConfiguration(packID: original.id)
- legacyStopped.consentedVersion = original.manifest.version
- legacyStopped.consentedContentSHA256 = original.manifest.contentSHA256
- try db.saveRulePackConfiguration(legacyStopped)
- _ = try db.raw(
- "UPDATE rule_packs SET updated = ? WHERE pack_id = ?",
- [.int(Int64(acceptedAt.timeIntervalSince1970)), .text(original.id)]
- ) { _ in 0 }
- let first = try db.reconcileRulePackCatalogBootstrap(
- db.loadRulePackCatalogBootstrap(catalogID: catalogID),
- definitions: catalog.definitions,
- catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
- )
- XCTAssertEqual(first.pendingReviewPackIDs, [original.id])
-
- try db.setRulePackReviewRequired(false, packID: original.id)
- let second = try db.reconcileRulePackCatalogBootstrap(
- db.loadRulePackCatalogBootstrap(catalogID: catalogID),
- definitions: catalog.definitions,
- catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
- )
-
- XCTAssertTrue(second.pendingReviewPackIDs.isEmpty)
+ XCTAssertFalse(try XCTUnwrap(repaired.configurations.first).enabled)
}
func testBootstrapDisablesAndPersistsLegacyOmittedActivePack() throws {
@@ -326,8 +197,7 @@ final class RulePackCatalogPersistenceTests: XCTestCase {
let bootstrap = try db.loadRulePackCatalogBootstrap(catalogID: catalogID)
let repaired = try db.reconcileRulePackCatalogBootstrap(
bootstrap,
- definitions: catalog.definitions,
- catalogIssuedAt: acceptedAt.addingTimeInterval(-100)
+ definitions: catalog.definitions
)
XCTAssertEqual(repaired.pendingReviewPackIDs, [legacyActive.packID])
From 4c55bc539bf226437eebec48b7b1e26a2cb79a3c Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Fri, 23 Oct 2026 01:00:59 -0700
Subject: [PATCH 12/13] Advance 0.15.5 release build
Co-Authored-By: Claude Code
---
project.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/project.yml b/project.yml
index 559efbb..138a7aa 100644
--- a/project.yml
+++ b/project.yml
@@ -12,7 +12,7 @@ settings:
CODE_SIGN_STYLE: Automatic
ENABLE_HARDENED_RUNTIME: YES
MARKETING_VERSION: "0.15.5"
- CURRENT_PROJECT_VERSION: "48"
+ CURRENT_PROJECT_VERSION: "49"
targets:
Flowlight:
type: application
From e98074a8751737b2d90d4f1898a6bd71a9bd8351 Mon Sep 17 00:00:00 2001
From: blessdyb
Date: Fri, 23 Oct 2026 09:23:09 -0700
Subject: [PATCH 13/13] Prepare separate Mac App Store distribution
Co-Authored-By: Claude Code
---
.github/workflows/ci.yml | 11 ++
Flowlight/Analysis/AgentWorkspace.swift | 60 ++++++++++-
Flowlight/App/AppNavigation.swift | 25 ++++-
Flowlight/App/FlowlightApp.swift | 14 ++-
Flowlight/App/TrafficMonitor.swift | 83 ++++++++++++--
Flowlight/Ask/FeatureGuide.swift | 20 +++-
Flowlight/Capture/ExtensionManager.swift | 102 +++++++++++++++---
Flowlight/Capture/ExtensionRecovery.swift | 8 ++
Flowlight/Capture/TrafficSource.swift | 11 ++
Flowlight/Flowlight-AppStore.entitlements | 20 ++++
.../Inspection/InspectionController.swift | 46 ++++++++
.../Inspection/ResponseScriptRunner.swift | 4 +
.../Localization/de.lproj/Localizable.strings | 15 +++
.../Localization/en.lproj/Localizable.strings | 15 +++
.../Localization/es.lproj/Localizable.strings | 15 +++
.../Localization/fr.lproj/Localizable.strings | 15 +++
.../Localization/it.lproj/Localizable.strings | 15 +++
.../Localization/ja.lproj/Localizable.strings | 15 +++
.../Localization/ko.lproj/Localizable.strings | 15 +++
.../pt-PT.lproj/Localizable.strings | 15 +++
.../zh-Hans.lproj/Localizable.strings | 15 +++
.../zh-Hant.lproj/Localizable.strings | 15 +++
Flowlight/Plugins/PluginScriptRunner.swift | 10 +-
Flowlight/Storage/TrafficDatabase.swift | 6 ++
Flowlight/UI/AgentsView.swift | 8 ++
Flowlight/UI/CaptureView.swift | 75 +++++++++++--
Flowlight/UI/CaptureWarning.swift | 2 +
Flowlight/UI/ContentView.swift | 13 ++-
Flowlight/UI/CoverageView.swift | 13 ++-
Flowlight/UI/InspectMenuItems.swift | 4 +
Flowlight/UI/ReportsView.swift | 2 +
Flowlight/UI/SettingsView.swift | 6 ++
FlowlightExtension/FilterDataProvider.swift | 6 ++
.../FlowlightExtension-AppStore.entitlements | 20 ++++
FlowlightExtension/IPCServer.swift | 3 +
FlowlightExtension/Info-AppStore.plist | 40 +++++++
FlowlightExtension/main.swift | 2 +
FlowlightTests/ExtensionSourceTests.swift | 22 ++--
FlowlightTests/Fixtures/IPCServer.txt | 1 +
project.yml | 66 +++++++++++-
scripts/check_app_store_product.sh | 39 +++++++
41 files changed, 836 insertions(+), 56 deletions(-)
create mode 100644 Flowlight/Flowlight-AppStore.entitlements
create mode 100644 FlowlightExtension/FlowlightExtension-AppStore.entitlements
create mode 100644 FlowlightExtension/Info-AppStore.plist
create mode 120000 FlowlightTests/Fixtures/IPCServer.txt
create mode 100755 scripts/check_app_store_product.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e8bc4c1..cf49525 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -178,6 +178,17 @@ jobs:
- name: Generate the project
run: xcodegen generate
+ # This compiles a different product graph and activation path from the Developer ID build. Ad-hoc signing
+ # deliberately strips protected entitlements; archive signing validates those with the distribution profiles.
+ - name: Build the Mac App Store product
+ run: |
+ xcodebuild build -project Flowlight.xcodeproj -scheme "Flowlight App Store" \
+ -configuration AppStore -destination 'generic/platform=macOS' -derivedDataPath "$RUNNER_TEMP/store-build" \
+ CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= CODE_SIGN_ENTITLEMENTS=
+
+ - name: Check the Mac App Store product structure
+ run: scripts/check_app_store_product.sh "$RUNNER_TEMP/store-build/Build/Products/AppStore/Flowlight.app"
+
# Ad-hoc signing: the entitlements that need a Developer ID profile are only exercised by the release build.
- name: Test
timeout-minutes: 40
diff --git a/Flowlight/Analysis/AgentWorkspace.swift b/Flowlight/Analysis/AgentWorkspace.swift
index daa347f..6535133 100644
--- a/Flowlight/Analysis/AgentWorkspace.swift
+++ b/Flowlight/Analysis/AgentWorkspace.swift
@@ -230,6 +230,7 @@ final class AgentWorkspaceStore: ObservableObject {
static let workspaces = "agents.workspaces"
static let scannedAt = "agents.scannedAt"
static let roots = "agents.roots"
+ static let rootBookmarks = "agents.rootBookmarks"
}
@Published private(set) var workspaces: [AgentWorkspace] = []
@@ -242,10 +243,18 @@ final class AgentWorkspaceStore: ObservableObject {
static let staleAfter: TimeInterval = 14 * 86400
private let defaults: UserDefaults
+ #if APP_STORE
+ private var securityScopedRoots: [URL] = []
+ #endif
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
+ #if APP_STORE
+ roots = Self.restoreBookmarks(defaults.data(forKey: Keys.rootBookmarks))
+ securityScopedRoots = roots.filter { $0.startAccessingSecurityScopedResource() }
+ #else
roots = (defaults.array(forKey: Keys.roots) as? [String] ?? []).map { URL(fileURLWithPath: $0) }
+ #endif
scannedAt = defaults.object(forKey: Keys.scannedAt) as? Date
if let data = defaults.data(forKey: Keys.workspaces),
let stored = try? JSONDecoder().decode([AgentWorkspace].self, from: data) {
@@ -268,21 +277,41 @@ final class AgentWorkspaceStore: ObservableObject {
scanning = true
let extraRoots = roots
Task.detached(priority: .utility) {
+ #if APP_STORE
+ let found = AgentWorkspaceScanner.scan(home: FileManager.default.temporaryDirectory, extraRoots: extraRoots)
+ #else
let found = AgentWorkspaceScanner.scan(extraRoots: extraRoots)
+ #endif
await MainActor.run { self.store(found) }
}
}
func addRoot(_ url: URL) {
- guard !roots.contains(url) else { return }
- roots.append(url)
+ let root = url.standardizedFileURL
+ guard !roots.contains(root) else { return }
+ #if APP_STORE
+ guard let bookmark = try? root.bookmarkData(options: .withSecurityScope) else { return }
+ guard root.startAccessingSecurityScopedResource() else { return }
+ securityScopedRoots.append(root)
+ roots.append(root)
+ saveBookmarks(adding: bookmark)
+ #else
+ roots.append(root)
defaults.set(roots.map(\.path), forKey: Keys.roots)
+ #endif
refresh(force: true)
}
func removeRoot(_ url: URL) {
+ #if APP_STORE
+ securityScopedRoots.filter { $0 == url }.forEach { $0.stopAccessingSecurityScopedResource() }
+ securityScopedRoots.removeAll { $0 == url }
+ roots.removeAll { $0 == url }
+ saveBookmarks()
+ #else
roots.removeAll { $0 == url }
defaults.set(roots.map(\.path), forKey: Keys.roots)
+ #endif
refresh(force: true)
}
@@ -302,6 +331,33 @@ final class AgentWorkspaceStore: ObservableObject {
defaults.set(scannedAt, forKey: Keys.scannedAt)
}
+ #if APP_STORE
+ private static func restoreBookmarks(_ data: Data?) -> [URL] {
+ guard let data,
+ let bookmarks = try? PropertyListDecoder().decode([Data].self, from: data) else { return [] }
+ var seen = Set()
+ return bookmarks.compactMap { bookmark in
+ var stale = false
+ guard let url = try? URL(
+ resolvingBookmarkData: bookmark,
+ options: [.withSecurityScope, .withoutUI],
+ relativeTo: nil,
+ bookmarkDataIsStale: &stale
+ ), !stale, seen.insert(url.standardizedFileURL.path).inserted else { return nil }
+ return url.standardizedFileURL
+ }
+ }
+
+ private func saveBookmarks(adding bookmark: Data? = nil) {
+ var bookmarks = roots.compactMap {
+ try? $0.bookmarkData(options: .withSecurityScope)
+ }
+ if let bookmark, bookmarks.isEmpty { bookmarks = [bookmark] }
+ defaults.set(try? PropertyListEncoder().encode(bookmarks), forKey: Keys.rootBookmarks)
+ defaults.removeObject(forKey: Keys.roots)
+ }
+ #endif
+
/// Workspaces belonging to an agent, its own first, then the projects it's configured in.
func workspaces(bundleID: String, name: String) -> [AgentWorkspace] {
let keys = Self.keys(bundleID: bundleID, name: name)
diff --git a/Flowlight/App/AppNavigation.swift b/Flowlight/App/AppNavigation.swift
index fb94f79..b4b566c 100644
--- a/Flowlight/App/AppNavigation.swift
+++ b/Flowlight/App/AppNavigation.swift
@@ -3,6 +3,15 @@ import SwiftUI
enum SidebarItem: String, CaseIterable, Identifiable {
case live, agents, reports, alerts, rules, ask, inspect, devices, coverage, capture
var id: String { rawValue }
+
+ static var availableCases: [SidebarItem] {
+ #if APP_STORE
+ allCases.filter { $0 != .inspect }
+ #else
+ allCases
+ #endif
+ }
+
/// The part of the documentation that explains this screen.
///
/// The docs are laid out in the sidebar's own groups — Traffic, Investigate, Control, Sources — so help
@@ -72,7 +81,7 @@ enum SidebarItem: String, CaseIterable, Identifiable {
/// digit, and the arithmetic that assumed there was turned `10` into a `Character` and trapped the moment
/// a tenth screen was added.
var shortcut: KeyEquivalent? {
- guard let index = Self.allCases.firstIndex(of: self), index < 10 else { return nil }
+ guard let index = Self.availableCases.firstIndex(of: self), index < 10 else { return nil }
return KeyEquivalent(Character("\(index == 9 ? 0 : index + 1)"))
}
@@ -105,7 +114,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
}
}
- var items: [SidebarItem] { SidebarItem.allCases.filter { $0.section == self } }
+ var items: [SidebarItem] { SidebarItem.availableCases.filter { $0.section == self } }
}
/// The three views inside Rules. This lives with the shared navigation state so an app-wide notice can take someone
@@ -142,7 +151,10 @@ struct ReportRequest: Equatable {
/// Shared navigation state so screens can link to each other (Live/Alerts → Reports).
@MainActor
final class AppNavigation: ObservableObject {
- @Published private(set) var selection: SidebarItem = SidebarItem(rawValue: UserDefaults.standard.string(forKey: "FLScreen") ?? "") ?? .live
+ @Published private(set) var selection: SidebarItem = {
+ let restored = SidebarItem(rawValue: UserDefaults.standard.string(forKey: "FLScreen") ?? "") ?? .live
+ return SidebarItem.availableCases.contains(restored) ? restored : .live
+ }()
@Published var rulesTab: RulesTab = .rules
@Published var reportRequest: ReportRequest?
@Published var inspectRequest: InspectRequest?
@@ -151,6 +163,7 @@ final class AppNavigation: ObservableObject {
/// Opens the Rules screen with this rule in the editor, unsaved. Writing a rule from a table row should land
/// somewhere it can be read back and changed, not quietly take effect out of sight.
func show(_ item: SidebarItem) {
+ guard SidebarItem.availableCases.contains(item) else { return }
if item == .rules { rulesTab = .rules }
selection = item
}
@@ -180,8 +193,9 @@ final class AppNavigation: ObservableObject {
/// traffic" is that field pre-filled rather than a second filtering mechanism. It also means the search
/// stays visible and editable: someone who arrives here can widen or narrow it without going back.
func showInspect(search: String, potentialHarmOnly: Bool = false) {
+ guard SidebarItem.availableCases.contains(.inspect) else { return }
inspectRequest = InspectRequest(search: search, potentialHarmOnly: potentialHarmOnly)
- selection = .inspect
+ show(.inspect)
}
/// Scope Inspect to one app or one destination, rather than searching for its name.
@@ -190,9 +204,10 @@ final class AppNavigation: ObservableObject {
/// Inspect searches response bodies too, so "Claude Code" matched every page whose HTML happens to contain
/// those words. The scope is still shown and still removable; it is just no longer a text match.
func showInspect(app: (bundleID: String, name: String)? = nil, host: String? = nil, potentialHarmOnly: Bool = false) {
+ guard SidebarItem.availableCases.contains(.inspect) else { return }
inspectRequest = InspectRequest(search: "", appID: app?.bundleID, appName: app?.name, host: host,
potentialHarmOnly: potentialHarmOnly)
- selection = .inspect
+ show(.inspect)
}
}
diff --git a/Flowlight/App/FlowlightApp.swift b/Flowlight/App/FlowlightApp.swift
index f66a787..963c20f 100644
--- a/Flowlight/App/FlowlightApp.swift
+++ b/Flowlight/App/FlowlightApp.swift
@@ -40,8 +40,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Not in tests (they share this process), and not in a demo or screenshot run: that is a throwaway instance
// with its own database, and it must never strip the enforcement the real running app owns.
guard !FlowlightApp.runningTests, !DemoData.isEnabled else { return }
+ #if !APP_STORE
SettingsEnforcer.shared.relaxAll()
UpdateLaunchPolicy.markCleanExit()
+ #endif
}
}
@@ -98,11 +100,15 @@ struct FlowlightApp: App {
monitor.start()
monitor.applyFocus(focus.scope)
extensionManager.refresh()
+ #if !APP_STORE
extensionManager.matchExtensionToApp { check in
guard check.repairing else { return }
Task { @MainActor in monitor.extensionVersionRepairing(check) }
}
+ #endif
+ #if !APP_STORE
updater.start()
+ #endif
}
}
@@ -125,9 +131,11 @@ struct FlowlightApp: App {
CommandGroup(replacing: .appInfo) {
Button(L("About Flowlight")) { AboutPanel.show() }
}
+ #if !APP_STORE
CommandGroup(after: .appInfo) {
CheckForUpdatesButton().environmentObject(updater)
}
+ #endif
CommandGroup(replacing: .help) {
// Help for wherever you are, first: someone who presses ⌘? on the Rules screen wants the part
// about rules, not the top of a page they then have to search.
@@ -142,7 +150,7 @@ struct FlowlightApp: App {
Button(L("Report an Issue")) { NSWorkspace.shared.open(Help.issues) }
}
CommandGroup(before: .sidebar) {
- ForEach(SidebarItem.allCases) { item in
+ ForEach(SidebarItem.availableCases) { item in
Button(item.title) { nav.show(item) }
.keyboardShortcut(item.shortcut ?? .init(" "), modifiers: item.shortcut == nil ? [] : .command)
}
@@ -154,6 +162,7 @@ struct FlowlightApp: App {
}
}
+ #if !APP_STORE
Window(L("Software Update"), id: "update") {
UpdateView().environmentObject(updater)
}
@@ -170,6 +179,7 @@ struct FlowlightApp: App {
.windowStyle(.hiddenTitleBar)
.defaultPosition(.center)
.restorationBehavior(.disabled)
+ #endif
MenuBarExtra {
MenuBarContent()
@@ -203,10 +213,12 @@ struct MenuBarLabel: View {
label
.onChange(of: focus.scope) { _, scope in monitor.applyFocus(scope) }
.onChange(of: backgroundOnly, initial: true) { _, _ in FlowlightApp.windowsChanged() }
+ #if !APP_STORE
// The menu bar item always exists, so a found update opens its window even with the main window closed.
.onChange(of: updater.showWindow) { _, show in
if show { openWindow(id: "update"); updater.showWindow = false }
}
+ #endif
}
@ViewBuilder private var label: some View {
diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift
index 365403b..04185e3 100644
--- a/Flowlight/App/TrafficMonitor.swift
+++ b/Flowlight/App/TrafficMonitor.swift
@@ -112,7 +112,11 @@ final class TrafficMonitor: ObservableObject {
AnomalySettings.registerDefaults()
LocalRiskSettings.registerDefaults()
InspectionBudget.registerDefaults()
+ #if APP_STORE
+ mode = .networkExtension
+ #else
mode = CaptureMode(rawValue: UserDefaults.standard.string(forKey: AnomalySettings.Keys.captureMode) ?? "") ?? .nettop
+ #endif
self.trafficSourceFactory = trafficSourceFactory
catalogTransportInjected = catalogTransport != nil
rulePacks = RulePackStore(currentAppVersion: ExtensionManager.appVersion)
@@ -273,9 +277,13 @@ final class TrafficMonitor: ObservableObject {
Notifier.configure()
// Either kind of notification needs permission: anomaly alerts, or "a new version is available".
let defaults = UserDefaults.standard
- if defaults.bool(forKey: AnomalySettings.Keys.notifications) || defaults.bool(forKey: UpdateChecker.Keys.automatic) {
- Notifier.requestAuthorization()
- }
+ #if APP_STORE
+ let wantsNotifications = defaults.bool(forKey: AnomalySettings.Keys.notifications)
+ #else
+ let wantsNotifications = defaults.bool(forKey: AnomalySettings.Keys.notifications)
+ || defaults.bool(forKey: UpdateChecker.Keys.automatic)
+ #endif
+ if wantsNotifications { Notifier.requestAuthorization() }
// Establish the signed catalog before loading saved consent and exceptions. Starting capture waits for both,
// so a corrupt higher cache can never briefly enforce bundled content, and enabled packs never omit exceptions.
Task { [weak self] in
@@ -298,6 +306,7 @@ final class TrafficMonitor: ObservableObject {
.map(\.bundleID)
engine.seedDiscoveredAgents(Set(agents))
}
+ #if !APP_STORE
updatePacketCapture()
// Not yet: the first thing someone sees should be their own traffic, not a request for a password.
// Hostnames are a refinement, and the offer explains itself better once there are rows on screen to
@@ -305,6 +314,7 @@ final class TrafficMonitor: ObservableObject {
timers.append(Timer.scheduledTimer(withTimeInterval: Self.onboardingDelay, repeats: false) { [weak self] _ in
MainActor.assumeIsolated { self?.offerCaptureSetupIfNeeded() }
})
+ #endif
refreshAlertCount()
timers.append(Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
MainActor.assumeIsolated { self?.tickLive() }
@@ -315,8 +325,10 @@ final class TrafficMonitor: ObservableObject {
timers.append(Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
+ #if !APP_STORE
self.sniffer.refreshInterface()
if self.captureState == .noPermission { self.updatePacketCapture() }
+ #endif
}
})
runMaintenance()
@@ -363,6 +375,9 @@ final class TrafficMonitor: ObservableObject {
}
func setMode(_ newMode: CaptureMode) {
+ #if APP_STORE
+ guard newMode == .networkExtension else { return }
+ #endif
guard newMode != mode else { return }
mode = newMode
extensionRetry?.cancel()
@@ -383,6 +398,11 @@ final class TrafficMonitor: ObservableObject {
/// Passive hostname capture is only needed for the nettop source; the extension sees payloads itself.
func updatePacketCapture() {
+ #if APP_STORE
+ sniffer.stop()
+ captureState = .stopped
+ return
+ #else
let onSampler = mode == .nettop || extensionFellBack
let wanted = !DemoData.isEnabled && onSampler && UserDefaults.standard.bool(forKey: AnomalySettings.Keys.packetCapture)
if wanted {
@@ -392,6 +412,7 @@ final class TrafficMonitor: ObservableObject {
sniffer.stop()
}
captureState = sniffer.state
+ #endif
}
/// How long the first run is left alone before hostname setup is offered.
@@ -451,6 +472,10 @@ final class TrafficMonitor: ObservableObject {
} else if mode == .networkExtension, canUseExtension, !extensionFellBack {
newSource = ExtensionTrafficSource()
} else {
+ #if APP_STORE
+ fallbackNote = L("Couldn't reach the filter extension. Enable it under Content filter, then press Refresh.")
+ newSource = UnavailableTrafficSource(message: fallbackNote!)
+ #else
if mode == .networkExtension {
fallbackNote = extensionFellBack
// Names the button that is on screen. It used to say "under Network Extension", which was
@@ -459,6 +484,7 @@ final class TrafficMonitor: ObservableObject {
: L("This build isn't signed for the Network Extension — sampling with nettop instead.")
}
newSource = NettopTrafficSource()
+ #endif
}
// Same idea as FLForceCaptureOnboarding: a way to see this state on a Mac where the filter works.
captureWarning = UserDefaults.standard.bool(forKey: "FLForceFilterWarning")
@@ -481,11 +507,19 @@ final class TrafficMonitor: ObservableObject {
self.updateRulePackStatus(result)
}
}
+ #if !APP_STORE
extensionSource.onRepairRequested = { [weak self] in
Task { @MainActor in self?.repairExtensionVersion() }
}
- extensionSource.onGaveUp = { [weak self] _ in
- Task { @MainActor in self?.fallBackToSampler() }
+ #endif
+ extensionSource.onGaveUp = { [weak self] message in
+ Task { @MainActor in
+ #if APP_STORE
+ self?.filterBecameUnavailable(message)
+ #else
+ self?.fallBackToSampler()
+ #endif
+ }
}
}
source = newSource
@@ -556,6 +590,7 @@ final class TrafficMonitor: ObservableObject {
}
}
+ #if !APP_STORE
/// The extension source has been refused often enough that plain redialling isn't the answer. Ask macOS which
/// build of the extension it is running: if it isn't this app's, re-activating replaces it, and that is the
/// one fault behind "the filter is enabled but the app can't reach it" that the app can repair by itself.
@@ -603,7 +638,25 @@ final class TrafficMonitor: ObservableObject {
}
}
}
+ #endif
+ #if APP_STORE
+ /// The Store product has no privileged sampler fallback. Leave enforcement visibly unavailable and let Refresh
+ /// create a new bounded reconnect ladder after the person enables the content filter in System Settings.
+ private func filterBecameUnavailable(_ message: String) {
+ guard mode == .networkExtension else { return }
+ extensionFellBack = true
+ let note = L("%@ — enable Flowlight under Content filter, then press Refresh.", message)
+ source?.stop()
+ let unavailable = UnavailableTrafficSource(message: note)
+ source = unavailable
+ liveRuleClaims.replace(nil)
+ liveRulePacks.replace(snapshot: nil)
+ if rulePacks.hasEnabledPacks { rulePacks.updateStatus(.watchingOnly) }
+ status = note
+ unavailable.start(sink: { _ in }, status: { _ in })
+ }
+ #else
/// The extension has stopped answering for good. Rather than leave someone with an orange dot and no data,
/// capture with the sampler and say plainly that this is what happened — their chosen source is still the
/// extension, and the status line says how to go back to it.
@@ -613,6 +666,7 @@ final class TrafficMonitor: ObservableObject {
startSource()
scheduleExtensionRetry()
}
+ #endif
/// Hands the capture source the allowlists it should refuse connections against. Only the ones the user has
/// switched to blocking travel: everything else stays a matter for alerts, and a source that can't block
@@ -867,15 +921,28 @@ final class TrafficMonitor: ObservableObject {
/// Read on the main actor and handed to the query that needs it, so nothing reaches across threads for it.
/// Settings only: no keys, no export headers, no endpoint. Those live in the Keychain and have no business
/// travelling to a model to answer "is export on?".
+ private var askCaptureSource: String {
+ #if APP_STORE
+ return extensionFellBack ? "Network Extension unavailable" : "Network Extension"
+ #else
+ return mode == .networkExtension && !extensionFellBack ? "Network Extension" : "nettop sampler"
+ #endif
+ }
+
func askEnvironment() -> AskEnvironment {
AskEnvironment(
appVersion: ExtensionManager.appVersion,
- captureSource: DemoData.isEnabled ? "demo data"
- : (mode == .networkExtension && !extensionFellBack ? "Network Extension" : "nettop sampler"),
+ captureSource: DemoData.isEnabled ? "demo data" : askCaptureSource,
captureStatus: status,
receiving: isReceiving,
extensionState: ExtensionManager.current?.state.label ?? "unknown",
- fellBackToSampler: extensionFellBack,
+ fellBackToSampler: {
+ #if APP_STORE
+ false
+ #else
+ extensionFellBack
+ #endif
+ }(),
canBlock: canBlock,
inspecting: inspection.enabled && inspection.running,
inspectionScope: inspection.scope.title,
diff --git a/Flowlight/Ask/FeatureGuide.swift b/Flowlight/Ask/FeatureGuide.swift
index c6b5052..17ba1b7 100644
--- a/Flowlight/Ask/FeatureGuide.swift
+++ b/Flowlight/Ask/FeatureGuide.swift
@@ -40,7 +40,8 @@ struct FeatureGuide: Equatable, Sendable, Identifiable {
extension FeatureGuide {
/// Everything Flowlight can do. A feature missing from here is a feature the Ask panel cannot explain, so it
/// is worth adding to when one is added to the app.
- static let all: [FeatureGuide] = [
+ static var all: [FeatureGuide] {
+ let guides: [FeatureGuide] = [
FeatureGuide(
id: "capture", title: "Capture source",
summary: "Where traffic comes from. The Network Extension is a content filter that sits in the data path and attributes every flow to its app; the nettop sampler needs no entitlement but samples once a second and misses flows shorter than that.",
@@ -196,7 +197,22 @@ extension FeatureGuide {
steps: ["Choose Flowlight › Check for Updates.", "Or install with Homebrew: brew install --cask xinbetween/tap/flowlight."],
screen: nil,
keywords: ["update", "upgrade", "version", "homebrew", "brew", "new release"]),
- ]
+ ]
+ #if APP_STORE
+ return guides.filter { !["hostnames", "guardrails", "inspection", "mocks", "updates"].contains($0.id) }
+ .map { guide in
+ guard guide.id == "capture" else { return guide }
+ var storeGuide = guide
+ storeGuide.summary = "Where traffic comes from. The Mac App Store build uses Flowlight's content-filter extension to attribute every flow to its app."
+ storeGuide.steps = ["Open Capture.", "Under Content filter, press Enable Filter and approve it in System Settings."]
+ storeGuide.caveats = ["macOS runs one content filter at a time. If a VPN or a security agent holds that slot, Flowlight cannot receive traffic."]
+ storeGuide.keywords = ["network extension", "content filter", "source", "no traffic", "nothing showing"]
+ return storeGuide
+ }
+ #else
+ return guides
+ #endif
+ }
/// The entries that match what was asked, best first. A plain keyword score rather than anything clever: the
/// corpus is twenty short entries, and a model that gets two near-misses alongside the right one can read.
diff --git a/Flowlight/Capture/ExtensionManager.swift b/Flowlight/Capture/ExtensionManager.swift
index a17ceb8..1abbbe2 100644
--- a/Flowlight/Capture/ExtensionManager.swift
+++ b/Flowlight/Capture/ExtensionManager.swift
@@ -1,7 +1,9 @@
import Foundation
@preconcurrency import NetworkExtension
import Security
+#if !APP_STORE
import SystemExtensions
+#endif
/// Installs the system extension and enables the content filter configuration.
@MainActor
@@ -14,11 +16,26 @@ final class ExtensionManager: NSObject, ObservableObject {
var label: String {
switch self {
case .unknown: return L("Unknown")
- case .notInstalled: return L("Not installed")
+ case .notInstalled:
+ #if APP_STORE
+ return L("Filter disabled")
+ #else
+ return L("Not installed")
+ #endif
case .awaitingApproval: return L("Waiting for approval in System Settings › General › Login Items & Extensions")
- case .installing: return L("Installing…")
+ case .installing:
+ #if APP_STORE
+ return L("Enabling…")
+ #else
+ return L("Installing…")
+ #endif
case .enabled: return L("Filter enabled")
- case .disabled: return L("Installed, filter disabled")
+ case .disabled:
+ #if APP_STORE
+ return L("Filter disabled")
+ #else
+ return L("Installed, filter disabled")
+ #endif
case .needsReboot: return L("Restart this Mac to finish replacing the filter")
case .failed(let m): return L("Failed: %@", m)
}
@@ -65,6 +82,7 @@ final class ExtensionManager: NSObject, ObservableObject {
Self.current = self
}
+ #if !APP_STORE
/// The properties request in flight, if any. The delegate callbacks are shared with installation requests
/// and a version check is otherwise indistinguishable from one.
private var versionCheck: OSSystemExtensionRequest?
@@ -74,6 +92,7 @@ final class ExtensionManager: NSObject, ObservableObject {
private var deactivation: OSSystemExtensionRequest?
/// Set while `reinstall()` is deactivating, so the deactivation's completion re-activates instead of stopping.
private var reinstalling = false
+ #endif
/// This app's version — the one the extension it ships was built alongside.
nonisolated static var appVersion: String {
@@ -88,9 +107,16 @@ final class ExtensionManager: NSObject, ObservableObject {
/// therefore no "restart to finish the update". Falls back to the app version if the embedded copy can't be read,
/// which is the old behaviour and errs towards not crying "stale".
nonisolated static var expectedExtensionVersion: String {
+ #if APP_STORE
+ let directory = "Contents/PlugIns"
+ let product = "\(FlowlightConstants.extensionBundleIdentifier).appex"
+ #else
+ let directory = "Contents/Library/SystemExtensions"
+ let product = "\(FlowlightConstants.extensionBundleIdentifier).systemextension"
+ #endif
let url = Bundle.main.bundleURL
- .appendingPathComponent("Contents/Library/SystemExtensions", isDirectory: true)
- .appendingPathComponent("\(FlowlightConstants.extensionBundleIdentifier).systemextension", isDirectory: true)
+ .appendingPathComponent(directory, isDirectory: true)
+ .appendingPathComponent(product, isDirectory: true)
guard let bundle = Bundle(url: url),
let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
!version.isEmpty else { return appVersion }
@@ -102,20 +128,37 @@ final class ExtensionManager: NSObject, ObservableObject {
static let isEntitled: Bool = {
guard let task = SecTaskCreateFromSelf(nil) else { return false }
let value = SecTaskCopyValueForEntitlement(task, "com.apple.developer.networking.networkextension" as CFString, nil)
- return (value as? [String])?.contains("content-filter-provider-systemextension") == true
+ #if APP_STORE
+ let required = "content-filter-provider"
+ #else
+ let required = "content-filter-provider-systemextension"
+ #endif
+ return (value as? [String])?.contains(required) == true
}()
var hasEntitlement: Bool { Self.isEntitled }
- /// macOS only activates system extensions from apps in /Applications.
- var isInApplications: Bool { Bundle.main.bundlePath.hasPrefix("/Applications/") }
+ /// Developer ID system extensions must live in /Applications; Store app extensions have no such requirement.
+ var isInApplications: Bool {
+ #if APP_STORE
+ true
+ #else
+ Bundle.main.bundlePath.hasPrefix("/Applications/")
+ #endif
+ }
/// Why activation cannot work right now, if anything.
var blocker: String? {
if !hasEntitlement {
+ #if APP_STORE
+ return L("This build isn't signed with the Network Extension entitlement.")
+ #else
return L("This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension.")
+ #endif
}
+ #if !APP_STORE
if !isInApplications { return L("Move Flowlight to /Applications. macOS only activates system extensions from there.") }
+ #endif
return nil
}
@@ -127,6 +170,9 @@ final class ExtensionManager: NSObject, ObservableObject {
/// `completion` is called once, when macOS has finished answering, with what it said. Capture uses it to
/// report a repair it has just asked for rather than going quiet for another thirty seconds.
func matchExtensionToApp(completion: ((VersionCheck) -> Void)? = nil) {
+ #if APP_STORE
+ completion?(VersionCheck(expectedVersion: Self.expectedExtensionVersion))
+ #else
guard hasEntitlement, isInApplications else { completion?(VersionCheck(expectedVersion: Self.expectedExtensionVersion)); return }
// One at a time. The ladder in ExtensionRecovery asks for this once per session, but a user pressing
// Refresh while it is in flight shouldn't start a second conversation with the same delegate.
@@ -138,8 +184,10 @@ final class ExtensionManager: NSObject, ObservableObject {
request.delegate = self
versionCheck = request
OSSystemExtensionManager.shared.submitRequest(request)
+ #endif
}
+ #if !APP_STORE
/// Ends the version check, whichever way it went, and hands the answer back exactly once.
private func finishVersionCheck() {
versionCheck = nil
@@ -147,6 +195,7 @@ final class ExtensionManager: NSObject, ObservableObject {
versionCheckCompletion = nil
completion?(versionCheckResult)
}
+ #endif
func refresh() {
guard hasEntitlement else { state = .notInstalled; return }
@@ -156,8 +205,12 @@ final class ExtensionManager: NSObject, ObservableObject {
if let error { self.state = .failed(error.localizedDescription); return }
let manager = NEFilterManager.shared()
if manager.providerConfiguration == nil { self.state = .notInstalled }
- else if manager.isEnabled { self.state = .enabled; self.upgradeToNeedsRebootIfHeld() }
- else { self.state = .disabled }
+ else if manager.isEnabled {
+ self.state = .enabled
+ #if !APP_STORE
+ self.upgradeToNeedsRebootIfHeld()
+ #endif
+ } else { self.state = .disabled }
}
}
}
@@ -168,6 +221,7 @@ final class ExtensionManager: NSObject, ObservableObject {
/// ground truth `systemextensionsctl` reports and upgrades the state to `.needsReboot`, which the Capture banner
/// turns into the restart-or-reinstall tip. Run after settling on `.enabled`, and only upgrades from it, so it
/// can't fight a state the user just changed.
+ #if !APP_STORE
private func upgradeToNeedsRebootIfHeld() {
Task { @MainActor in
let extensions = await SystemExtensionScan.read()
@@ -176,6 +230,7 @@ final class ExtensionManager: NSObject, ObservableObject {
self.state = .needsReboot
}
}
+ #endif
/// Whether the scan shows *our* previous copies holding the slot until a restart: the current build enabled and
/// at least one earlier copy "waiting to uninstall on reboot". This is the reliable signal — read from what
@@ -193,13 +248,20 @@ final class ExtensionManager: NSObject, ObservableObject {
func activate() {
if let blocker { state = .failed(blocker); return }
state = .installing
+ #if APP_STORE
+ setFilterEnabled(true)
+ #else
let request = OSSystemExtensionRequest.activationRequest(forExtensionWithIdentifier: FlowlightConstants.extensionBundleIdentifier,
queue: .main)
request.delegate = self
OSSystemExtensionManager.shared.submitRequest(request)
+ #endif
}
func deactivate() {
+ #if APP_STORE
+ setFilterEnabled(false)
+ #else
setFilterEnabled(false) { [weak self] in
guard let self else { return }
let request = OSSystemExtensionRequest.deactivationRequest(forExtensionWithIdentifier: FlowlightConstants.extensionBundleIdentifier,
@@ -208,6 +270,7 @@ final class ExtensionManager: NSObject, ObservableObject {
self.deactivation = request
OSSystemExtensionManager.shared.submitRequest(request)
}
+ #endif
}
/// Uninstall the filter and install it again, without a restart. macOS runs one content filter at a time, and
@@ -215,8 +278,12 @@ final class ExtensionManager: NSObject, ObservableObject {
/// the current build should have. Deactivating drops those copies; activating brings the current build back —
/// into the slot this time. It is the alternative to restarting the Mac when it is holding the old filter.
func reinstall() {
+ #if APP_STORE
+ setFilterEnabled(false) { [weak self] in self?.setFilterEnabled(true) }
+ #else
reinstalling = true
deactivate()
+ #endif
}
func setFilterEnabled(_ enabled: Bool, completion: (() -> Void)? = nil) {
@@ -225,12 +292,13 @@ final class ExtensionManager: NSObject, ObservableObject {
Task { @MainActor in
guard let self else { return }
if let loadError { self.state = .failed(loadError.localizedDescription); completion?(); return }
- if manager.providerConfiguration == nil {
- let config = NEFilterProviderConfiguration()
- config.filterSockets = true
- config.filterPackets = false
- manager.providerConfiguration = config
- }
+ let config = manager.providerConfiguration ?? NEFilterProviderConfiguration()
+ #if APP_STORE
+ config.filterDataProviderBundleIdentifier = FlowlightConstants.extensionBundleIdentifier
+ #endif
+ config.filterSockets = true
+ config.filterPackets = false
+ manager.providerConfiguration = config
manager.localizedDescription = "Flowlight"
manager.isEnabled = enabled
manager.saveToPreferences { saveError in
@@ -245,6 +313,7 @@ final class ExtensionManager: NSObject, ObservableObject {
}
}
+#if !APP_STORE
extension ExtensionManager: OSSystemExtensionRequestDelegate {
nonisolated private static func versionIdentity(_ properties: OSSystemExtensionProperties) -> String {
let version = properties.bundleShortVersion
@@ -320,3 +389,4 @@ extension ExtensionManager: OSSystemExtensionRequestDelegate {
}
}
}
+#endif
diff --git a/Flowlight/Capture/ExtensionRecovery.swift b/Flowlight/Capture/ExtensionRecovery.swift
index e7c69c9..c2f25a8 100644
--- a/Flowlight/Capture/ExtensionRecovery.swift
+++ b/Flowlight/Capture/ExtensionRecovery.swift
@@ -63,11 +63,15 @@ struct ExtensionRecovery {
return .fallBack
}
let after = Self.delay(forAttempt: attempts)
+ #if APP_STORE
+ return .redial(after: after)
+ #else
if attempts >= Self.attemptsBeforeVersionCheck, !versionChecked {
versionChecked = true
return .repairVersion(after: after)
}
return .redial(after: after)
+ #endif
}
/// The connection came up and the extension registered. The ladder starts again from the bottom, but what
@@ -100,7 +104,11 @@ struct ExtensionRecovery {
return L("%@ — checking whether the installed extension matches this app, then reconnecting in %llds",
message, Int(after))
case .fallBack:
+ #if APP_STORE
+ return L("%@ — the content filter is unavailable", message)
+ #else
return L("%@ — switching to the nettop sampler", message)
+ #endif
}
}
}
diff --git a/Flowlight/Capture/TrafficSource.swift b/Flowlight/Capture/TrafficSource.swift
index 2581073..8c86724 100644
--- a/Flowlight/Capture/TrafficSource.swift
+++ b/Flowlight/Capture/TrafficSource.swift
@@ -26,6 +26,17 @@ extension TrafficSource {
func setRulePacks(_ snapshot: RulePackSnapshot?) {}
}
+/// A truthful fail-closed capture source for builds that cannot use their only supported provider.
+final class UnavailableTrafficSource: TrafficSource {
+ let message: String
+ var displayName: String { L("Network Extension") }
+
+ init(message: String) { self.message = message }
+
+ func start(sink: @escaping ([TrafficBatch]) -> Void, status: @escaping (String) -> Void) { status(message) }
+ func stop() {}
+}
+
enum CaptureMode: String, CaseIterable, Identifiable {
case networkExtension, nettop
var id: String { rawValue }
diff --git a/Flowlight/Flowlight-AppStore.entitlements b/Flowlight/Flowlight-AppStore.entitlements
new file mode 100644
index 0000000..f00e97c
--- /dev/null
+++ b/Flowlight/Flowlight-AppStore.entitlements
@@ -0,0 +1,20 @@
+
+
+
+
+ com.apple.developer.networking.networkextension
+
+ content-filter-provider
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ $(TeamIdentifierPrefix)com.flowlight.shared
+
+ com.apple.security.files.user-selected.read-only
+
+ com.apple.security.network.client
+
+
+
diff --git a/Flowlight/Inspection/InspectionController.swift b/Flowlight/Inspection/InspectionController.swift
index da1a073..4a2130f 100644
--- a/Flowlight/Inspection/InspectionController.swift
+++ b/Flowlight/Inspection/InspectionController.swift
@@ -151,6 +151,13 @@ final class InspectionController: ObservableObject {
Keys.enabled: false, Keys.port: 8877, Keys.scope: Scope.all.rawValue,
Keys.neverInspect: Self.defaultNeverInspect, Keys.systemProxy: false,
])
+ #if APP_STORE
+ // The Store sandbox cannot install a root certificate, mutate system proxy settings, or rewrite agent
+ // configuration. Clear any preference copied from a direct build before lifecycle setup can act on it.
+ UserDefaults.standard.set(false, forKey: Keys.enabled)
+ UserDefaults.standard.set(false, forKey: Keys.systemProxy)
+ UserDefaults.standard.removeObject(forKey: Keys.monitoredAgents)
+ #endif
proxy.observer = recorder
recorder.proxyPort = { [proxy] in proxy.port }
recorder.budget = { InspectionBudget.load() }
@@ -350,10 +357,17 @@ final class InspectionController: ObservableObject {
return out
}
+ #if APP_STORE
+ var enabled: Bool {
+ get { false }
+ set { }
+ }
+ #else
var enabled: Bool {
get { UserDefaults.standard.bool(forKey: Keys.enabled) }
set { UserDefaults.standard.set(newValue, forKey: Keys.enabled); objectWillChange.send(); apply() }
}
+ #endif
var scope: Scope {
get { Scope(rawValue: UserDefaults.standard.string(forKey: Keys.scope) ?? "") ?? .agents }
@@ -511,6 +525,9 @@ final class InspectionController: ObservableObject {
/// Everything the simple switch does: create the certificate, trust it, start the proxy and route apps through it.
/// Trusting and changing the proxy both need an administrator, so they go in one prompt rather than two.
func setEnabled(_ on: Bool) {
+ #if APP_STORE
+ return
+ #else
lastError = nil
if on {
do { try ca.ensure() } catch { lastError = error.localizedDescription; return }
@@ -563,6 +580,7 @@ final class InspectionController: ObservableObject {
}.value
finishSetup(on: on, failure: failure)
}
+ #endif
}
private func finishSetup(on: Bool, failure: String?) {
@@ -587,6 +605,15 @@ final class InspectionController: ObservableObject {
/// Starts or stops the proxy to match the setting.
func apply() {
+ #if APP_STORE
+ running = false
+ port = nil
+ trusted = false
+ caExists = false
+ systemProxyOn = false
+ proxy.stop()
+ return
+ #else
refreshStatus()
if enabled && !DemoData.isEnabled {
do { try ca.ensure() } catch { lastError = error.localizedDescription; return }
@@ -596,6 +623,7 @@ final class InspectionController: ObservableObject {
if systemProxyOn { setSystemProxy(false) }
proxy.stop()
}
+ #endif
}
func refreshStatus() {
@@ -633,6 +661,9 @@ final class InspectionController: ObservableObject {
/// monitored agent's file is pointed at it; otherwise none is, because a file pointing at a proxy that isn't
/// listening would stop the agent reaching its API. Idempotent, so it is safe to call on every state change.
func reconcileEnforcement() {
+ #if APP_STORE
+ return
+ #else
guard !DemoData.isEnabled else { return }
let enforcer = SettingsEnforcer.shared
let monitored = monitoredAgents
@@ -652,16 +683,19 @@ final class InspectionController: ObservableObject {
enforcer.relaxAll()
if janitorInstalled { ProxyJanitor.uninstall(); janitorInstalled = false }
}
+ #endif
}
/// On launch, clear anything a previous run left enforced before the proxy is running again: a crash skips the
/// normal strip, so the file could still point at last run's dead proxy. Normal routing re-applies once the
/// proxy is up, through `reconcileEnforcement`.
func reconcileEnforcementAtLaunch() {
+ #if !APP_STORE
guard !DemoData.isEnabled else { return }
SettingsEnforcer.shared.relaxAll()
ProxyJanitor.uninstall()
janitorInstalled = false
+ #endif
}
private func prune() {
@@ -672,6 +706,9 @@ final class InspectionController: ObservableObject {
// MARK: Certificate
func trustCertificate() {
+ #if APP_STORE
+ return
+ #else
lastError = nil
Task.detached { [ca] in
do { try ca.trust() } catch {
@@ -679,6 +716,7 @@ final class InspectionController: ObservableObject {
}
await MainActor.run { self.refreshStatus() }
}
+ #endif
}
/// Turns inspection off, removes the system proxy, deletes the CA, its trust setting and every recorded exchange.
@@ -745,6 +783,9 @@ final class InspectionController: ObservableObject {
/// Opens a Terminal window with the setup applied; agents started there are inspected.
func openInspectedTerminal() {
+ #if APP_STORE
+ return
+ #else
let script = FileManager.default.temporaryDirectory.appendingPathComponent("Flowlight Inspected Shell.command")
let shell = ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
// The greeting is translated, so it is escaped before it goes into the script: a quote or a $ in a
@@ -766,6 +807,7 @@ final class InspectionController: ObservableObject {
} catch {
lastError = L("Couldn't open a Terminal window: %@", error.localizedDescription)
}
+ #endif
}
/// The proxy auto-config served to apps that follow system proxy settings. It falls back to a direct connection
@@ -833,6 +875,9 @@ final class InspectionController: ObservableObject {
/// Points every enabled network service's automatic proxy configuration at Flowlight's PAC file, or restores it.
/// macOS asks for an administrator password.
func setSystemProxy(_ on: Bool) {
+ #if APP_STORE
+ return
+ #else
let services = SystemProxy.services()
guard !services.isEmpty else { lastError = L("No network services found."); return }
let pac = "http://127.0.0.1:\(port ?? configuredPort)/proxy.pac"
@@ -854,6 +899,7 @@ final class InspectionController: ObservableObject {
}
UserDefaults.standard.set(on, forKey: Keys.systemProxy)
refreshStatus()
+ #endif
}
// MARK: Matching
diff --git a/Flowlight/Inspection/ResponseScriptRunner.swift b/Flowlight/Inspection/ResponseScriptRunner.swift
index 02f0493..01818b1 100644
--- a/Flowlight/Inspection/ResponseScriptRunner.swift
+++ b/Flowlight/Inspection/ResponseScriptRunner.swift
@@ -21,6 +21,9 @@ final class ResponseScriptRunner: @unchecked Sendable {
private init() {}
func transform(rules: [ResponseTransformRule], request: ResponseTransformRequest, responseHead: HTTPHead, responseBody: Data) -> Result? {
+ #if APP_STORE
+ return nil
+ #else
guard responseBody.count <= Self.maxPayloadBytes,
let representation = ResponseRepresentation.detect(headers: responseHead.headers, body: responseBody) else { return nil }
var body = responseBody
@@ -34,6 +37,7 @@ final class ResponseScriptRunner: @unchecked Sendable {
}
guard !names.isEmpty, body != responseBody else { return nil }
return Result(body: body, note: names.joined(separator: " · "))
+ #endif
}
private func run(rule: ResponseTransformRule, request: ResponseTransformRequest, representation: ResponseRepresentation, body: Data) -> Data? {
diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings
index b02d3e1..e0b80ae 100644
--- a/Flowlight/Localization/de.lproj/Localizable.strings
+++ b/Flowlight/Localization/de.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "Nicht installiert";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "Warten auf Freigabe in Systemeinstellungen › Allgemein › Anmeldeobjekte & Erweiterungen";
"Installing…" = "Wird installiert…";
+"Enabling…" = "Wird aktiviert…";
"Filter enabled" = "Filter aktiviert";
+"Filter disabled" = "Filter deaktiviert";
"Installed, filter disabled" = "Installiert, Filter deaktiviert";
"Restart this Mac to finish replacing the filter" = "Diesen Mac neu starten, um den Filterwechsel abzuschließen";
"Failed: %@" = "Fehlgeschlagen: %@";
"unknown" = "unbekannt";
+"This build isn't signed with the Network Extension entitlement." = "Dieser Build ist nicht mit der Berechtigung für die Netzwerkerweiterung signiert.";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "Dieser Build ist nicht mit der Berechtigung für die Netzwerkerweiterung signiert. Mit scripts/build-signed.sh und einem Team bauen, dem Apple content-filter-provider-systemextension gewährt hat.";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "Flowlight nach /Applications verschieben. macOS aktiviert Systemerweiterungen nur von dort.";
@@ -623,6 +626,15 @@
"Delete all recorded traffic, baselines and alerts?" = "Allen aufgezeichneten Datenverkehr, alle Basislinien und Warnungen löschen?";
"Delete everything" = "Alles löschen";
"nettop sampler · the extension didn't answer" = "nettop-Sampler · die Erweiterung hat nicht geantwortet";
+"Network Extension · unavailable" = "Netzwerkerweiterung · nicht verfügbar";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Die Filtererweiterung ist nicht erreichbar. Aktiviere sie unter Inhaltsfilter und klicke dann auf Aktualisieren.";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — aktiviere Flowlight unter Inhaltsfilter und klicke dann auf Aktualisieren.";
+"%@ — the content filter is unavailable" = "%@ — der Inhaltsfilter ist nicht verfügbar";
+"The content filter is unavailable" = "Der Inhaltsfilter ist nicht verfügbar";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight konnte den eingebetteten Inhaltsfilter nicht erreichen. Aktiviere ihn in den Systemeinstellungen und klicke dann auf Aktualisieren.";
+"Reconnect to the content filter" = "Erneut mit dem Inhaltsfilter verbinden";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Wenn ein VPN oder Sicherheitsagent diesen Platz bereits belegt – Palo Alto Networks GlobalProtect, CrowdStrike Falcon und ähnliche verwenden ihn –, kann der Flowlight-Filter keinen Datenverkehr empfangen, bis dieser Filter deaktiviert wird.";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "Der Filter antwortet nicht, daher empfängt Flowlight keinen Datenverkehr. Aktiviere ihn unter Inhaltsfilter und klicke dann auf Aktualisieren.";
"%@ · receiving" = "%@ · empfängt";
"%@ · nothing arriving yet" = "%@ · noch kommt nichts an";
"Source" = "Quelle";
@@ -634,6 +646,9 @@
"Nothing is arriving" = "Es kommt nichts an";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight konnte die Filtererweiterung nicht erreichen und tastet mit nettop ab, damit Sie weiter Daten erhalten. Ihre gewählte Quelle ist weiterhin die Erweiterung.";
"Install & Enable Filter" = "Filter installieren & aktivieren";
+"Enable Filter" = "Filter aktivieren";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "Der Inhaltsfilter (NEFilterDataProvider) ordnet geeignete TCP- und UDP-Flows über Audit-Token ihren Quell-Apps zu, entnimmt TLS-SNI, HTTP-Host und DNS-Antworten und sendet Sekundenzusammenfassungen über XPC an diese App. Er ist in die App-Store-App eingebettet und verwendet die Berechtigung für die Netzwerkerweiterung (content-filter-provider). Er verweigert eine Verbindung nur dort, wo eine Regel es verlangt; ohne durchsetzende Regel meldet er und sonst nichts.";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "Der eingebettete Filter bleibt aktiv, bis Sie Deaktivieren wählen. Während die Flowlight-App geschlossen ist, puffert die Erweiterung Zusammenfassungen zur späteren Übertragung bis zu ihrem Aufbewahrungslimit.";
"Disable Filter" = "Filter deaktivieren";
"Uninstall" = "Deinstallieren";
"Refresh" = "Aktualisieren";
diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings
index b39f416..c16617b 100644
--- a/Flowlight/Localization/en.lproj/Localizable.strings
+++ b/Flowlight/Localization/en.lproj/Localizable.strings
@@ -194,11 +194,14 @@
"Not installed" = "Not installed";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "Waiting for approval in System Settings › General › Login Items & Extensions";
"Installing…" = "Installing…";
+"Enabling…" = "Enabling…";
"Filter enabled" = "Filter enabled";
+"Filter disabled" = "Filter disabled";
"Installed, filter disabled" = "Installed, filter disabled";
"Restart this Mac to finish replacing the filter" = "Restart this Mac to finish replacing the filter";
"Failed: %@" = "Failed: %@";
"unknown" = "unknown";
+"This build isn't signed with the Network Extension entitlement." = "This build isn't signed with the Network Extension entitlement.";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension.";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "Move Flowlight to /Applications. macOS only activates system extensions from there.";
@@ -595,6 +598,15 @@
"Delete all recorded traffic, baselines and alerts?" = "Delete all recorded traffic, baselines and alerts?";
"Delete everything" = "Delete everything";
"nettop sampler · the extension didn't answer" = "nettop sampler · the extension didn't answer";
+"Network Extension · unavailable" = "Network Extension · unavailable";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Couldn't reach the filter extension. Enable it under Content filter, then press Refresh.";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — enable Flowlight under Content filter, then press Refresh.";
+"%@ — the content filter is unavailable" = "%@ — the content filter is unavailable";
+"The content filter is unavailable" = "The content filter is unavailable";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh.";
+"Reconnect to the content filter" = "Reconnect to the content filter";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled.";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh.";
"%@ · receiving" = "%@ · receiving";
"%@ · nothing arriving yet" = "%@ · nothing arriving yet";
"Source" = "Source";
@@ -606,6 +618,7 @@
"Nothing is arriving" = "Nothing is arriving";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension.";
"Install & Enable Filter" = "Install & Enable Filter";
+"Enable Filter" = "Enable Filter";
"Disable Filter" = "Disable Filter";
"Uninstall" = "Uninstall";
"Refresh" = "Refresh";
@@ -621,6 +634,8 @@
"macOS calls the filter when an eligible connection opens, providing better coverage of short requests than periodic sampling. Traffic from before activation is not available, some system traffic is exempt from content filters, and byte counts come from the filter's statistics reports." = "macOS calls the filter when an eligible connection opens, providing better coverage of short requests than periodic sampling. Traffic from before activation is not available, some system traffic is exempt from content filters, and byte counts come from the filter's statistics reports.";
"It keeps filtering after you quit Flowlight" = "It keeps filtering after you quit Flowlight";
"The system extension runs independently and remains active until you select Disable or Uninstall. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "The system extension runs independently and remains active until you select Disable or Uninstall. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit.";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else.";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit.";
"macOS runs one content filter at a time" = "macOS runs one content filter at a time";
"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter installs and connects but is never asked to filter anything, so nothing appears. Use the sampler on those Macs." = "If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter installs and connects but is never asked to filter anything, so nothing appears. Use the sampler on those Macs.";
"Flowlight reads /usr/bin/nettop once per second. You get per-process, per-connection byte counts with no entitlements, and protocols from port heuristics." = "Flowlight reads /usr/bin/nettop once per second. You get per-process, per-connection byte counts with no entitlements, and protocols from port heuristics.";
diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings
index 1bea9ba..d384c6c 100644
--- a/Flowlight/Localization/es.lproj/Localizable.strings
+++ b/Flowlight/Localization/es.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "No instalada";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "Esperando la autorización en Ajustes del Sistema › General › Ítems de inicio y extensiones";
"Installing…" = "Instalando…";
+"Enabling…" = "Activando…";
"Filter enabled" = "Filtro activado";
+"Filter disabled" = "Filtro desactivado";
"Installed, filter disabled" = "Instalada, filtro desactivado";
"Restart this Mac to finish replacing the filter" = "Reinicia este Mac para terminar de sustituir el filtro";
"Failed: %@" = "Error: %@";
"unknown" = "desconocido";
+"This build isn't signed with the Network Extension entitlement." = "Esta versión no está firmada con el permiso de la extensión de red.";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "Esta versión no está firmada con el permiso de la extensión de red. Compila con scripts/build-signed.sh usando un equipo al que Apple haya concedido content-filter-provider-systemextension.";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "Mueve Flowlight a /Applications. macOS solo activa las extensiones del sistema desde ahí.";
@@ -623,6 +626,15 @@
"Delete all recorded traffic, baselines and alerts?" = "¿Eliminar todo el tráfico registrado, las líneas base y las alertas?";
"Delete everything" = "Eliminar todo";
"nettop sampler · the extension didn't answer" = "Muestreador nettop · la extensión no ha respondido";
+"Network Extension · unavailable" = "Extensión de red · no disponible";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "No se pudo acceder a la extensión de filtro. Actívala en Filtro de contenido y pulsa Actualizar.";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — activa Flowlight en Filtro de contenido y pulsa Actualizar.";
+"%@ — the content filter is unavailable" = "%@ — el filtro de contenido no está disponible";
+"The content filter is unavailable" = "El filtro de contenido no está disponible";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight no pudo acceder al filtro de contenido integrado. Actívalo en Ajustes del Sistema y pulsa Actualizar.";
+"Reconnect to the content filter" = "Volver a conectar con el filtro de contenido";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Si una VPN o un agente de seguridad ya ocupa ese espacio —Palo Alto Networks GlobalProtect, CrowdStrike Falcon y otros similares lo utilizan—, el filtro de Flowlight no podrá recibir tráfico hasta que se desactive dicho filtro.";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "El filtro no responde, por lo que Flowlight no recibe tráfico. Actívalo en Filtro de contenido y pulsa Actualizar.";
"%@ · receiving" = "%@ · recibiendo";
"%@ · nothing arriving yet" = "%@ · todavía no llega nada";
"Source" = "Fuente";
@@ -634,6 +646,9 @@
"Nothing is arriving" = "No llega nada";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight no ha podido contactar con la extensión de filtro y está muestreando con nettop para que sigas teniendo datos. Tu fuente elegida sigue siendo la extensión.";
"Install & Enable Filter" = "Instalar y activar el filtro";
+"Enable Filter" = "Activar el filtro";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "El filtro de contenido (NEFilterDataProvider) atribuye los flujos TCP y UDP admitidos a sus apps de origen mediante tokens de auditoría, extrae el SNI de TLS, la cabecera Host de HTTP y las respuestas DNS, y envía resúmenes de un segundo a esta app por XPC. Está integrado en la app de App Store y usa el permiso de extensión de red (content-filter-provider). Solo rechaza una conexión cuando una regla lo indica; sin ninguna regla que se aplique, se limita a informar.";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "El filtro integrado permanece activo hasta que seleccionas Desactivar. Mientras la app Flowlight está cerrada, la extensión almacena resúmenes para entregarlos más tarde, hasta su límite de retención.";
"Disable Filter" = "Desactivar el filtro";
"Uninstall" = "Desinstalar";
"Refresh" = "Actualizar";
diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings
index 4ca3e89..47bbcbd 100644
--- a/Flowlight/Localization/fr.lproj/Localizable.strings
+++ b/Flowlight/Localization/fr.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "Non installé";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "En attente d'autorisation dans Réglages Système › Général › Ouverture et extensions";
"Installing…" = "Installation…";
+"Enabling…" = "Activation…";
"Filter enabled" = "Filtre activé";
+"Filter disabled" = "Filtre désactivé";
"Installed, filter disabled" = "Installé, filtre désactivé";
"Restart this Mac to finish replacing the filter" = "Redémarrer ce Mac pour terminer le remplacement du filtre";
"Failed: %@" = "Échec : %@";
"unknown" = "inconnu";
+"This build isn't signed with the Network Extension entitlement." = "Cette version n'est pas signée avec le droit d'accès Extension réseau.";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "Cette version n'est pas signée avec le droit d'accès Extension réseau. Compiler avec scripts/build-signed.sh en utilisant une équipe à laquelle Apple a accordé content-filter-provider-systemextension.";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "Déplacer Flowlight dans /Applications. macOS n'active les extensions système que depuis cet emplacement.";
@@ -623,6 +626,15 @@
"Delete all recorded traffic, baselines and alerts?" = "Supprimer tout le trafic enregistré, les références et les alertes ?";
"Delete everything" = "Tout supprimer";
"nettop sampler · the extension didn't answer" = "Échantillonneur nettop · l'extension n'a pas répondu";
+"Network Extension · unavailable" = "Extension réseau · indisponible";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Impossible de joindre l’extension de filtrage. Activez-la dans Filtre de contenu, puis cliquez sur Actualiser.";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — activez Flowlight dans Filtre de contenu, puis cliquez sur Actualiser.";
+"%@ — the content filter is unavailable" = "%@ — le filtre de contenu est indisponible";
+"The content filter is unavailable" = "Le filtre de contenu est indisponible";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight n’a pas pu joindre le filtre de contenu intégré. Activez-le dans Réglages Système, puis cliquez sur Actualiser.";
+"Reconnect to the content filter" = "Se reconnecter au filtre de contenu";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Si un VPN ou un agent de sécurité occupe déjà cet emplacement — Palo Alto Networks GlobalProtect, CrowdStrike Falcon et autres l’utilisent — le filtre Flowlight ne peut recevoir aucun trafic tant que cet autre filtre n’est pas désactivé.";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "Le filtre ne répond pas, Flowlight ne reçoit donc aucun trafic. Activez-le dans Filtre de contenu, puis cliquez sur Actualiser.";
"%@ · receiving" = "%@ · réception en cours";
"%@ · nothing arriving yet" = "%@ · rien ne parvient encore";
"Source" = "Source";
@@ -634,6 +646,9 @@
"Nothing is arriving" = "Rien n'arrive";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight n'a pas pu joindre l'extension de filtrage et échantillonne avec nettop pour que vous ayez tout de même des données. La source choisie reste l'extension.";
"Install & Enable Filter" = "Installer et activer le filtre";
+"Enable Filter" = "Activer le filtre";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "Le filtre de contenu (NEFilterDataProvider) attribue les flux TCP et UDP éligibles à leurs apps d'origine grâce aux jetons d'audit, extrait le SNI TLS, l'en-tête HTTP Host et les réponses DNS, puis envoie à cette app des résumés d'une seconde via XPC. Il est intégré à l'app de l'App Store et utilise le droit d'accès Extension réseau (content-filter-provider). Il ne refuse une connexion que lorsqu'une règle le demande ; sans règle en vigueur, il se contente de signaler.";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "Le filtre intégré reste actif jusqu'à ce que vous sélectionniez Désactiver. Lorsque l'app Flowlight est fermée, les résumés sont mis en mémoire tampon par l'extension en vue d'une livraison ultérieure, dans la limite de sa capacité de rétention.";
"Disable Filter" = "Désactiver le filtre";
"Uninstall" = "Désinstaller";
"Refresh" = "Actualiser";
diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings
index 5e0cfc0..40224f3 100644
--- a/Flowlight/Localization/it.lproj/Localizable.strings
+++ b/Flowlight/Localization/it.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "Non installata";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "In attesa di approvazione in Impostazioni di Sistema › Generali › Elementi login ed estensioni";
"Installing…" = "Installazione…";
+"Enabling…" = "Attivazione…";
"Filter enabled" = "Filtro attivo";
+"Filter disabled" = "Filtro disattivato";
"Installed, filter disabled" = "Installata, filtro disattivato";
"Restart this Mac to finish replacing the filter" = "Riavvia questo Mac per completare la sostituzione del filtro";
"Failed: %@" = "Non riuscita: %@";
"unknown" = "sconosciuto";
+"This build isn't signed with the Network Extension entitlement." = "Questa build non è firmata con l'entitlement Network Extension.";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "Questa build non è firmata con l'entitlement Network Extension. Compila con scripts/build-signed.sh usando un team a cui Apple ha concesso content-filter-provider-systemextension.";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "Sposta Flowlight in /Applications. macOS attiva le estensioni di sistema solo da lì.";
@@ -623,6 +626,15 @@
"Delete all recorded traffic, baselines and alerts?" = "Eliminare tutto il traffico registrato, le linee di base e gli avvisi?";
"Delete everything" = "Elimina tutto";
"nettop sampler · the extension didn't answer" = "Campionatore nettop · l'estensione non ha risposto";
+"Network Extension · unavailable" = "Estensione di rete · non disponibile";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Impossibile raggiungere l’estensione del filtro. Abilitala in Filtro contenuti, quindi premi Aggiorna.";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — abilita Flowlight in Filtro contenuti, quindi premi Aggiorna.";
+"%@ — the content filter is unavailable" = "%@ — il filtro contenuti non è disponibile";
+"The content filter is unavailable" = "Il filtro contenuti non è disponibile";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight non è riuscito a raggiungere il filtro contenuti incorporato. Abilitalo in Impostazioni di Sistema, quindi premi Aggiorna.";
+"Reconnect to the content filter" = "Riconnetti al filtro contenuti";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Se una VPN o un agente di sicurezza occupa già quello slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon e simili lo utilizzano — il filtro di Flowlight non può ricevere traffico finché tale filtro non viene disabilitato.";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "Il filtro non risponde, quindi Flowlight non riceve traffico. Abilitalo in Filtro contenuti, quindi premi Aggiorna.";
"%@ · receiving" = "%@ · ricezione in corso";
"%@ · nothing arriving yet" = "%@ · ancora nessun dato in arrivo";
"Source" = "Origine";
@@ -634,6 +646,9 @@
"Nothing is arriving" = "Non arriva nulla";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight non è riuscito a raggiungere l'estensione filtro e sta campionando con nettop per continuare a fornirti dati. L'origine scelta resta l'estensione.";
"Install & Enable Filter" = "Installa e attiva il filtro";
+"Enable Filter" = "Attiva il filtro";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "Il filtro contenuti (NEFilterDataProvider) attribuisce i flussi TCP e UDP ammessi alle app di origine tramite i token di audit, estrae SNI TLS, Host HTTP e risposte DNS e invia a questa app riepiloghi di un secondo tramite XPC. È integrato nell'app dell'App Store e usa l'entitlement Network Extension (content-filter-provider). Rifiuta una connessione solo dove una regola lo prevede; in assenza di una regola da applicare, si limita a segnalare.";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "Il filtro integrato rimane attivo finché non selezioni Disattiva. Mentre l'app Flowlight è chiusa, i riepiloghi vengono memorizzati nel buffer dall'estensione per una consegna successiva, fino al suo limite di conservazione.";
"Disable Filter" = "Disattiva il filtro";
"Uninstall" = "Disinstalla";
"Refresh" = "Aggiorna";
diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings
index 9223e31..6d4e9f7 100644
--- a/Flowlight/Localization/ja.lproj/Localizable.strings
+++ b/Flowlight/Localization/ja.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "未インストール";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "システム設定 › 一般 › ログイン項目と機能拡張 での承認を待っています";
"Installing…" = "インストール中…";
+"Enabling…" = "有効化中…";
"Filter enabled" = "フィルタは有効";
+"Filter disabled" = "フィルタは無効";
"Installed, filter disabled" = "インストール済み・フィルタは無効";
"Restart this Mac to finish replacing the filter" = "フィルタの置き換えを完了するには、この Mac を再起動してください";
"Failed: %@" = "失敗: %@";
"unknown" = "不明";
+"This build isn't signed with the Network Extension entitlement." = "このビルドにはネットワーク機能拡張の entitlement が署名されていません。";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "このビルドにはネットワーク機能拡張の entitlement が署名されていません。Apple から content-filter-provider-systemextension を付与されたチームで scripts/build-signed.sh を使ってビルドしてください。";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "Flowlight を /Applications に移動してください。macOS はそこからしかシステム機能拡張を有効にしません。";
@@ -623,6 +626,15 @@
"Delete all recorded traffic, baselines and alerts?" = "記録したトラフィック・基準値・アラートをすべて削除しますか?";
"Delete everything" = "すべて削除";
"nettop sampler · the extension didn't answer" = "nettop サンプラー · 機能拡張が応答しませんでした";
+"Network Extension · unavailable" = "ネットワーク機能拡張 · 利用不可";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "フィルター機能拡張に接続できませんでした。コンテンツフィルターで有効にしてから、更新を押してください。";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — コンテンツフィルターで Flowlight を有効にしてから、更新を押してください。";
+"%@ — the content filter is unavailable" = "%@ — コンテンツフィルターを利用できません";
+"The content filter is unavailable" = "コンテンツフィルターを利用できません";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight は内蔵コンテンツフィルターに接続できませんでした。システム設定で有効にしてから、更新を押してください。";
+"Reconnect to the content filter" = "コンテンツフィルターに再接続";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "VPN やセキュリティエージェントがそのスロットを使用している場合(Palo Alto Networks GlobalProtect、CrowdStrike Falcon など)、そのフィルターを無効にするまで Flowlight のフィルターはトラフィックを受信できません。";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "フィルターが応答していないため、Flowlight はトラフィックを受信していません。コンテンツフィルターで有効にしてから、更新を押してください。";
"%@ · receiving" = "%@ · 受信中";
"%@ · nothing arriving yet" = "%@ · まだ届いていません";
"Source" = "ソース";
@@ -634,6 +646,9 @@
"Nothing is arriving" = "何も届いていません";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight はフィルタ機能拡張に接続できなかったため、データが途切れないよう nettop でサンプリングしています。選択中のソースは機能拡張のままです。";
"Install & Enable Filter" = "フィルタをインストールして有効にする";
+"Enable Filter" = "フィルタを有効にする";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "コンテンツフィルタ(NEFilterDataProvider)は、対象となる TCP と UDP のフローを audit token でソースのアプリに結び付け、TLS の SNI、HTTP の Host、DNS の応答を取り出し、1 秒ごとの要約を XPC でこのアプリに送ります。App Store 版アプリに組み込まれ、ネットワーク機能拡張の entitlement(content-filter-provider)を使用します。接続を拒否するのはルールが指示した場合だけで、適用するルールがなければ報告だけを行います。";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "組み込みフィルタは「無効にする」を選ぶまで有効なままです。Flowlight アプリが閉じている間、要約は保持上限の範囲で機能拡張に一時保存され、後で配信されます。";
"Disable Filter" = "フィルタを無効にする";
"Uninstall" = "アンインストール";
"Refresh" = "更新";
diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings
index 94e6ff1..ebde9fc 100644
--- a/Flowlight/Localization/ko.lproj/Localizable.strings
+++ b/Flowlight/Localization/ko.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "설치되지 않음";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "시스템 설정 › 일반 › 로그인 항목 및 확장 프로그램에서 승인 대기 중";
"Installing…" = "설치 중…";
+"Enabling…" = "활성화 중…";
"Filter enabled" = "필터 활성화됨";
+"Filter disabled" = "필터 비활성화됨";
"Installed, filter disabled" = "설치됨, 필터 비활성화";
"Restart this Mac to finish replacing the filter" = "필터 교체를 완료하려면 이 Mac을 다시 시작하세요";
"Failed: %@" = "실패: %@";
"unknown" = "알 수 없음";
+"This build isn't signed with the Network Extension entitlement." = "이 빌드에는 네트워크 확장 프로그램 권한(entitlement)이 서명되어 있지 않습니다.";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "이 빌드에는 네트워크 확장 프로그램 권한(entitlement)이 서명되어 있지 않습니다. Apple이 content-filter-provider-systemextension 권한을 부여한 팀으로 scripts/build-signed.sh를 사용해 빌드하세요.";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "Flowlight를 /Applications로 옮기세요. macOS는 그 위치에 있는 앱의 시스템 확장 프로그램만 활성화합니다.";
@@ -646,6 +649,15 @@
"Delete all recorded traffic, baselines and alerts?" = "기록된 트래픽, 기준선, 알림을 모두 삭제할까요?";
"Delete everything" = "모두 삭제";
"nettop sampler · the extension didn't answer" = "nettop 샘플러 · 확장 프로그램이 응답하지 않았습니다";
+"Network Extension · unavailable" = "네트워크 확장 · 사용할 수 없음";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "필터 확장에 연결할 수 없습니다. 콘텐츠 필터에서 활성화한 다음 새로 고침을 누르세요.";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — 콘텐츠 필터에서 Flowlight를 활성화한 다음 새로 고침을 누르세요.";
+"%@ — the content filter is unavailable" = "%@ — 콘텐츠 필터를 사용할 수 없습니다";
+"The content filter is unavailable" = "콘텐츠 필터를 사용할 수 없습니다";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight가 내장 콘텐츠 필터에 연결할 수 없습니다. 시스템 설정에서 활성화한 다음 새로 고침을 누르세요.";
+"Reconnect to the content filter" = "콘텐츠 필터에 다시 연결";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "VPN 또는 보안 에이전트가 이미 해당 슬롯을 사용 중이라면(Palo Alto Networks GlobalProtect, CrowdStrike Falcon 등), 그 필터를 비활성화할 때까지 Flowlight 필터는 트래픽을 받을 수 없습니다.";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "필터가 응답하지 않아 Flowlight가 트래픽을 받지 못하고 있습니다. 콘텐츠 필터에서 활성화한 다음 새로 고침을 누르세요.";
"%@ · receiving" = "%@ · 수신 중";
"%@ · nothing arriving yet" = "%@ · 아직 들어오는 데이터 없음";
"Source" = "소스";
@@ -657,6 +669,9 @@
"Nothing is arriving" = "들어오는 데이터가 없습니다";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight가 필터 확장 프로그램에 연결할 수 없어, 데이터가 끊기지 않도록 nettop으로 샘플링하고 있습니다. 선택한 소스는 여전히 확장 프로그램입니다.";
"Install & Enable Filter" = "필터 설치 및 활성화";
+"Enable Filter" = "필터 활성화";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "콘텐츠 필터(NEFilterDataProvider)는 감사 토큰으로 대상 TCP·UDP 흐름을 해당 앱에 귀속시키고, TLS SNI, HTTP Host, DNS 응답을 추출해 1초 단위 요약을 XPC로 이 앱에 보냅니다. App Store 앱에 내장되며 네트워크 확장 프로그램 권한(content-filter-provider)을 사용합니다. 규칙이 차단을 지시한 경우에만 연결을 거부하며, 적용되는 규칙이 없으면 기록만 합니다.";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "내장 필터는 비활성화를 선택할 때까지 활성 상태를 유지합니다. Flowlight 앱이 닫혀 있는 동안 요약은 보존 한도 내에서 확장 프로그램에 버퍼링되었다가 나중에 전달됩니다.";
"Disable Filter" = "필터 비활성화";
"Uninstall" = "제거";
"Refresh" = "새로 고침";
diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings
index 9c90159..ed4f3bf 100644
--- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings
+++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "Não instalada";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "À espera de aprovação em Definições do Sistema › Geral › Itens de início de sessão e extensões";
"Installing…" = "A instalar…";
+"Enabling…" = "A ativar…";
"Filter enabled" = "Filtro ativado";
+"Filter disabled" = "Filtro desativado";
"Installed, filter disabled" = "Instalada, filtro desativado";
"Restart this Mac to finish replacing the filter" = "Reinicie este Mac para concluir a substituição do filtro";
"Failed: %@" = "Falhou: %@";
"unknown" = "desconhecido";
+"This build isn't signed with the Network Extension entitlement." = "Esta compilação não está assinada com o entitlement da extensão de rede.";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "Esta compilação não está assinada com o entitlement da extensão de rede. Compile com scripts/build-signed.sh usando uma equipa à qual a Apple tenha concedido content-filter-provider-systemextension.";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "Mova o Flowlight para /Applications. O macOS só ativa extensões de sistema a partir dessa pasta.";
@@ -646,6 +649,15 @@
"Delete all recorded traffic, baselines and alerts?" = "Eliminar todo o tráfego registado, as linhas de base e os alertas?";
"Delete everything" = "Eliminar tudo";
"nettop sampler · the extension didn't answer" = "amostrador nettop · a extensão não respondeu";
+"Network Extension · unavailable" = "Extensão de rede · indisponível";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "Não foi possível aceder à extensão do filtro. Ative-a em Filtro de conteúdo e prima Atualizar.";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — ative o Flowlight em Filtro de conteúdo e prima Atualizar.";
+"%@ — the content filter is unavailable" = "%@ — o filtro de conteúdo está indisponível";
+"The content filter is unavailable" = "O filtro de conteúdo está indisponível";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "O Flowlight não conseguiu aceder ao filtro de conteúdo incorporado. Ative-o nas Definições do Sistema e prima Atualizar.";
+"Reconnect to the content filter" = "Voltar a ligar ao filtro de conteúdo";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "Se uma VPN ou um agente de segurança já ocupar esse espaço — Palo Alto Networks GlobalProtect, CrowdStrike Falcon e semelhantes utilizam-no — o filtro do Flowlight não pode receber tráfego até esse filtro ser desativado.";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "O filtro não está a responder, pelo que o Flowlight não recebe tráfego. Ative-o em Filtro de conteúdo e prima Atualizar.";
"%@ · receiving" = "%@ · a receber";
"%@ · nothing arriving yet" = "%@ · ainda nada a chegar";
"Source" = "Origem";
@@ -657,6 +669,9 @@
"Nothing is arriving" = "Não está a chegar nada";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "O Flowlight não conseguiu alcançar a extensão de filtro e está a fazer amostragem com o nettop para continuar a haver dados. A origem que escolheu continua a ser a extensão.";
"Install & Enable Filter" = "Instalar e ativar o filtro";
+"Enable Filter" = "Ativar o filtro";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "O filtro de conteúdo (NEFilterDataProvider) atribui os fluxos TCP e UDP elegíveis às apps de origem através de audit tokens, extrai o SNI de TLS, o Host de HTTP e as respostas DNS, e envia resumos de um segundo para esta app por XPC. Está incorporado na app da App Store e usa o entitlement da extensão de rede (content-filter-provider). Só recusa uma ligação quando uma regra assim o determina; sem uma regra a aplicar, apenas comunica.";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "O filtro incorporado permanece ativo até selecionar Desativar. Enquanto a app Flowlight está fechada, os resumos são guardados temporariamente pela extensão para entrega posterior, até ao respetivo limite de retenção.";
"Disable Filter" = "Desativar o filtro";
"Uninstall" = "Desinstalar";
"Refresh" = "Atualizar";
diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
index 43e32c2..3c696d3 100644
--- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
+++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "未安装";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "正在等待在“系统设置”›“通用”›“登录项与扩展”中批准";
"Installing…" = "正在安装…";
+"Enabling…" = "正在启用…";
"Filter enabled" = "过滤器已启用";
+"Filter disabled" = "过滤器已停用";
"Installed, filter disabled" = "已安装,过滤器已停用";
"Restart this Mac to finish replacing the filter" = "重新启动这台 Mac 以完成过滤器替换";
"Failed: %@" = "失败:%@";
"unknown" = "未知";
+"This build isn't signed with the Network Extension entitlement." = "此构建未使用网络扩展授权签名。";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "此构建未使用网络扩展授权签名。请使用 Apple 已授予 content-filter-provider-systemextension 的团队,通过 scripts/build-signed.sh 构建。";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "请将 Flowlight 移动到 /Applications。macOS 只会从那里激活系统扩展。";
@@ -646,6 +649,15 @@
"Delete all recorded traffic, baselines and alerts?" = "删除所有已记录的流量、基线和警报?";
"Delete everything" = "全部删除";
"nettop sampler · the extension didn't answer" = "nettop 采样器 · 扩展没有响应";
+"Network Extension · unavailable" = "网络扩展 · 不可用";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "无法连接过滤器扩展。请在“内容过滤器”中启用它,然后点按“刷新”。";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — 请在“内容过滤器”中启用 Flowlight,然后点按“刷新”。";
+"%@ — the content filter is unavailable" = "%@ — 内容过滤器不可用";
+"The content filter is unavailable" = "内容过滤器不可用";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight 无法连接内置内容过滤器。请在“系统设置”中启用它,然后点按“刷新”。";
+"Reconnect to the content filter" = "重新连接内容过滤器";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "如果 VPN 或安全代理已占用该位置(Palo Alto Networks GlobalProtect、CrowdStrike Falcon 等都会使用它),则在停用该过滤器之前,Flowlight 的过滤器无法接收流量。";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "过滤器没有响应,因此 Flowlight 未收到流量。请在“内容过滤器”中启用它,然后点按“刷新”。";
"%@ · receiving" = "%@ · 正在接收";
"%@ · nothing arriving yet" = "%@ · 尚无数据到达";
"Source" = "来源";
@@ -657,6 +669,9 @@
"Nothing is arriving" = "没有数据到达";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight 无法连接过滤器扩展,正在用 nettop 采样,好让你仍能获得数据。你选择的来源仍然是扩展。";
"Install & Enable Filter" = "安装并启用过滤器";
+"Enable Filter" = "启用过滤器";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "内容过滤器(NEFilterDataProvider)通过审计令牌把符合条件的 TCP 和 UDP 流归属到其来源应用,提取 TLS SNI、HTTP Host 和 DNS 应答,并通过 XPC 把每秒的摘要发送给本应用。它内嵌在 App Store 版应用中,并使用网络扩展授权(content-filter-provider)。只有在规则要求时才会拒绝连接;没有要执行的规则时只做记录。";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "内嵌过滤器会保持启用,直到你选择“停用”。Flowlight 应用关闭期间,扩展会在保留上限内暂存摘要,供稍后传送。";
"Disable Filter" = "停用过滤器";
"Uninstall" = "卸载";
"Refresh" = "刷新";
diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
index 55c6af6..28e3eb8 100644
--- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
+++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings
@@ -192,11 +192,14 @@
"Not installed" = "未安裝";
"Waiting for approval in System Settings › General › Login Items & Extensions" = "正在等待「系統設定」›「一般」›「登入項目與延伸功能」中的允許";
"Installing…" = "正在安裝…";
+"Enabling…" = "正在啟用…";
"Filter enabled" = "過濾器已啟用";
+"Filter disabled" = "過濾器已停用";
"Installed, filter disabled" = "已安裝,過濾器已停用";
"Restart this Mac to finish replacing the filter" = "重新啟動這台 Mac 以完成過濾器替換";
"Failed: %@" = "失敗:%@";
"unknown" = "未知";
+"This build isn't signed with the Network Extension entitlement." = "這個版本沒有以網路延伸功能的權利簽署。";
"This build isn't signed with the Network Extension entitlement. Build with scripts/build-signed.sh using a team that Apple has granted content-filter-provider-systemextension." = "這個版本沒有以網路延伸功能的權利簽署。請使用已取得 Apple 授予 content-filter-provider-systemextension 的團隊,透過 scripts/build-signed.sh 建置。";
"Move Flowlight to /Applications. macOS only activates system extensions from there." = "請把 Flowlight 移到 /Applications。macOS 只會從那裡啟用系統延伸功能。";
@@ -646,6 +649,15 @@
"Delete all recorded traffic, baselines and alerts?" = "要刪除所有已記錄的流量、基線和警示嗎?";
"Delete everything" = "全部刪除";
"nettop sampler · the extension didn't answer" = "nettop 取樣器 · 延伸功能沒有回應";
+"Network Extension · unavailable" = "網路延伸功能 · 無法使用";
+"Couldn't reach the filter extension. Enable it under Content filter, then press Refresh." = "無法連接過濾器延伸功能。請在「內容過濾器」中啟用,然後按一下「重新整理」。";
+"%@ — enable Flowlight under Content filter, then press Refresh." = "%@ — 請在「內容過濾器」中啟用 Flowlight,然後按一下「重新整理」。";
+"%@ — the content filter is unavailable" = "%@ — 內容過濾器無法使用";
+"The content filter is unavailable" = "內容過濾器無法使用";
+"Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh." = "Flowlight 無法連接內嵌內容過濾器。請在「系統設定」中啟用,然後按一下「重新整理」。";
+"Reconnect to the content filter" = "重新連接內容過濾器";
+"If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled." = "如果 VPN 或安全代理程式已佔用該位置(Palo Alto Networks GlobalProtect、CrowdStrike Falcon 等都會使用),則停用該過濾器之前,Flowlight 的過濾器無法接收流量。";
+"The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh." = "過濾器沒有回應,因此 Flowlight 未收到流量。請在「內容過濾器」中啟用,然後按一下「重新整理」。";
"%@ · receiving" = "%@ · 正在接收";
"%@ · nothing arriving yet" = "%@ · 還沒有資料傳入";
"Source" = "來源";
@@ -657,6 +669,9 @@
"Nothing is arriving" = "沒有資料傳入";
"Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension." = "Flowlight 無法連接過濾器延伸功能,因此改用 nettop 取樣,讓你仍然有資料可看。你選擇的來源仍然是延伸功能。";
"Install & Enable Filter" = "安裝並啟用過濾器";
+"Enable Filter" = "啟用過濾器";
+"The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else." = "內容過濾器(NEFilterDataProvider)會透過 audit token 把符合條件的 TCP 和 UDP 流量歸給來源 App,取出 TLS SNI、HTTP Host 和 DNS 回應,並透過 XPC 把每秒的摘要送給這個 App。它內嵌在 App Store 版 App 中,並使用網路延伸功能權利(content-filter-provider)。只有在規則要求時才會拒絕連線;沒有要執行的規則時只做記錄。";
+"The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit." = "內嵌過濾器會保持啟用,直到你選擇「停用」。Flowlight App 關閉期間,延伸功能會在保留上限內暫存摘要,以供稍後傳送。";
"Disable Filter" = "停用過濾器";
"Uninstall" = "解除安裝";
"Refresh" = "重新整理";
diff --git a/Flowlight/Plugins/PluginScriptRunner.swift b/Flowlight/Plugins/PluginScriptRunner.swift
index 73a1b60..5b97aa1 100644
--- a/Flowlight/Plugins/PluginScriptRunner.swift
+++ b/Flowlight/Plugins/PluginScriptRunner.swift
@@ -19,10 +19,17 @@ final class PluginScriptRunner: @unchecked Sendable {
private init() {}
func evaluate(exchange: HTTPExchange, manifests: [PluginManifest]) -> [PluginFindingDraft] {
- manifests.flatMap { evaluate(exchange: exchange, manifest: $0) }
+ #if APP_STORE
+ return []
+ #else
+ return manifests.flatMap { evaluate(exchange: exchange, manifest: $0) }
+ #endif
}
func evaluate(exchange: HTTPExchange, manifest: PluginManifest) -> [PluginFindingDraft] {
+ #if APP_STORE
+ return []
+ #else
guard manifest.enabled, manifest.source == .installed, !manifest.script.isEmpty,
manifest.script.lengthOfBytes(using: .utf8) <= Self.maxScriptBytes else { return [] }
let input: [String: Any] = [
@@ -69,6 +76,7 @@ final class PluginScriptRunner: @unchecked Sendable {
let object = try? JSONSerialization.jsonObject(with: captured) as? [String: Any],
let rawFindings = object["findings"] as? [[String: Any]] else { return [] }
return rawFindings.prefix(8).compactMap { draft(from: $0, manifest: manifest, exchange: exchange) }
+ #endif
}
private func draft(from object: [String: Any], manifest: PluginManifest, exchange: HTTPExchange) -> PluginFindingDraft? {
diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift
index d0e5480..faea2b9 100644
--- a/Flowlight/Storage/TrafficDatabase.swift
+++ b/Flowlight/Storage/TrafficDatabase.swift
@@ -240,8 +240,14 @@ final class TrafficDatabase: @unchecked Sendable {
static func defaultURL() -> URL {
let fm = FileManager.default
// Touching an un-entitled group container can trigger a "would like to access data from other apps" prompt.
+ #if APP_STORE
+ // A Store build's Application Support is inside its sandbox. Do not silently move a direct build's live
+ // database—or an old App Group WAL—across channels; a future import must be explicit and transactional.
+ let groupURL: URL? = nil
+ #else
let groupURL = hasAppGroupEntitlement
? fm.containerURL(forSecurityApplicationGroupIdentifier: FlowlightConstants.appGroupIdentifier) : nil
+ #endif
return resolveURL(appSupport: fm.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0], group: groupURL, fm: fm)
}
diff --git a/Flowlight/UI/AgentsView.swift b/Flowlight/UI/AgentsView.swift
index 4c96ebd..a6e24de 100644
--- a/Flowlight/UI/AgentsView.swift
+++ b/Flowlight/UI/AgentsView.swift
@@ -1121,7 +1121,11 @@ struct AgentSetupBar: View {
Text(L("Flowlight can also list what %@ is set up with: its skills, subagents, slash commands, hooks and MCP servers. It reads those config files on this Mac and keeps only their names; nothing is sent anywhere.", agent))
.foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
HStack(spacing: 8) {
+ #if APP_STORE
+ Button(L("Add project folder…")) { pickFolder() }
+ #else
Button(L("Look for agent configuration")) { store.refresh(force: true) }
+ #endif
Button(L("Why?")) { openURL(Help.agentConfiguration) }.buttonStyle(.link)
}
} else {
@@ -1211,6 +1215,9 @@ struct InspectionHint: View {
private var inspecting: Bool { inspection.enabled && inspection.running }
var body: some View {
+ #if APP_STORE
+ EmptyView()
+ #else
VStack(alignment: .leading, spacing: 6) {
Text(message)
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
@@ -1256,6 +1263,7 @@ struct InspectionHint: View {
Text(L("%@ starts %@, so the proxy settings have to reach %@ rather than a new window — a shell opened now would never be any agent's parent. Restarting it ends every session it is currently running, including this one. It is started again with the same command and its HTTPS pointed at Flowlight.",
supervisor?.name ?? "", agent, supervisor?.name ?? ""))
}
+ #endif
}
/// What is actually starting this agent, when it is something other than a terminal.
diff --git a/Flowlight/UI/CaptureView.swift b/Flowlight/UI/CaptureView.swift
index aaa311f..30775ab 100644
--- a/Flowlight/UI/CaptureView.swift
+++ b/Flowlight/UI/CaptureView.swift
@@ -32,7 +32,9 @@ struct CaptureView: View {
if !otherFilters.isEmpty { OtherFiltersNotice(filters: otherFilters) }
sourceSection
filterSection
+ #if !APP_STORE
if monitor.mode == .nettop { HostnameSection() }
+ #endif
storageSection
}
// A settings panel is read, not scanned, and a line of prose stretched across a maximised window is
@@ -44,15 +46,23 @@ struct CaptureView: View {
// The one thing worth a trip to this screen to find out — what is feeding the app right now — said in the
// title bar, so it can be read from any other screen's neighbour.
.navigationSubtitle(subtitle)
+ #if !APP_STORE
// Which other content filters exist decides whether ours can work at all, so it's read when the screen opens.
.task { otherFilters = SystemExtensionScan.competingFilters(in: await SystemExtensionScan.read()) }
+ #endif
.confirmationDialog(L("Delete all recorded traffic, baselines and alerts?"), isPresented: $confirmClear) {
Button(L("Delete everything"), role: .destructive) { monitor.clearAllData() }
}
}
private var subtitle: String {
- if monitor.extensionFellBack { return L("nettop sampler · the extension didn't answer") }
+ if monitor.extensionFellBack {
+ #if APP_STORE
+ return L("Network Extension · unavailable")
+ #else
+ return L("nettop sampler · the extension didn't answer")
+ #endif
+ }
let source = monitor.mode == .networkExtension ? L("Network Extension") : L("nettop sampler")
return monitor.isReceiving ? L("%@ · receiving", source) : L("%@ · nothing arriving yet", source)
}
@@ -64,9 +74,11 @@ struct CaptureView: View {
CaptureHeading(L("Source"))
VStack(spacing: 0) {
sourceRow(.networkExtension)
+ #if !APP_STORE
Divider().padding(.leading, 50)
sourceRow(.nettop)
samplerNotes
+ #endif
}
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 10))
@@ -149,7 +161,13 @@ struct CaptureView: View {
}
private var statusHeadline: String {
- if monitor.extensionFellBack { return L("Arriving from nettop, not the extension") }
+ if monitor.extensionFellBack {
+ #if APP_STORE
+ return L("The content filter is unavailable")
+ #else
+ return L("Arriving from nettop, not the extension")
+ #endif
+ }
return monitor.isReceiving ? L("Data is arriving") : L("Nothing is arriving")
}
@@ -169,17 +187,25 @@ struct CaptureView: View {
.font(.caption.monospaced()).foregroundStyle(.tertiary)
}
}
+ #if !APP_STORE
if extensionManager.state == .needsReboot { rebootTip }
+ #endif
if let blocker = extensionManager.blocker { CaptureNote(text: blocker) }
if monitor.extensionFellBack {
+ #if APP_STORE
+ CaptureNote(text: L("Flowlight couldn't reach the embedded content filter. Enable it in System Settings, then press Refresh."),
+ icon: "exclamationmark.triangle.fill")
+ #else
CaptureNote(text: L("Flowlight couldn't reach the filter extension and is sampling with nettop so you still get data. Your chosen source is still the extension."),
icon: "arrow.uturn.down.circle.fill")
+ #endif
}
extensionButtons
}
.padding(.horizontal, 12).padding(.top, 12).padding(.bottom, 14)
}
+ #if !APP_STORE
/// Shown when the update landed but macOS is still running the previous filter because the superseded copies
/// hold the single content-filter slot until they are cleared. It names the two things that clear them —
/// reinstalling the filter (no restart), or restarting the Mac — rather than leaving someone watching the
@@ -199,15 +225,23 @@ struct CaptureView: View {
.background(RoundedRectangle(cornerRadius: 10).fill(FL.warning.opacity(0.12)))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(FL.warning.opacity(0.35)))
}
+ #endif
private var extensionButtons: some View {
HStack(spacing: 8) {
+ #if APP_STORE
+ Button(L("Enable Filter")) { extensionManager.activate() }
+ .disabled(extensionManager.blocker != nil || extensionManager.state == .enabled)
+ #else
Button(L("Install & Enable Filter")) { extensionManager.activate() }
- .disabled(extensionManager.blocker != nil)
+ .disabled(extensionManager.blocker != nil || extensionManager.state == .enabled)
+ #endif
Button(L("Disable Filter")) { extensionManager.setFilterEnabled(false) }
.disabled(extensionManager.state != .enabled)
+ #if !APP_STORE
Button(L("Uninstall")) { extensionManager.deactivate() }
.disabled(!extensionManager.hasEntitlement)
+ #endif
Button(L("Refresh")) {
extensionManager.refresh()
// Also redial the extension: reloading its settings tells you nothing about whether the app can
@@ -217,7 +251,11 @@ struct CaptureView: View {
.help(L("Re-check the filter and reconnect to it"))
if monitor.extensionFellBack {
Button(L("Try the Extension Again")) { monitor.reconnectSource() }
+ #if APP_STORE
+ .help(L("Reconnect to the content filter"))
+ #else
.help(L("Stop sampling with nettop and reconnect to the filter extension"))
+ #endif
}
Spacer(minLength: 0)
}
@@ -249,29 +287,54 @@ struct CaptureView: View {
/// fail because of them.
private var extensionFacts: String {
let entitlement = ExtensionManager.isEntitled ? L("entitled") : L("not entitled")
+ #if APP_STORE
+ return entitlement
+ #else
let location = extensionManager.isInApplications ? L("in /Applications") : L("not in /Applications")
return "\(entitlement) · \(location)"
+ #endif
}
/// What the filter is and what it costs, folded away. Every word of it matters the first time and none of it
/// matters the twentieth, which is exactly what a disclosure is for.
+ private var filterDescription: String {
+ #if APP_STORE
+ L("The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It is embedded in the App Store app and uses the Network Extension entitlement (content-filter-provider). It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else.")
+ #else
+ L("The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It requires the Network Extension entitlement (content-filter-provider-systemextension) on a paid developer team, and the app must be in the Applications folder. It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else.")
+ #endif
+ }
+
private var filterNotes: some View {
VStack(alignment: .leading, spacing: 10) {
- Text(L("The content filter (NEFilterDataProvider) attributes eligible TCP and UDP flows to their source apps via audit tokens, extracts TLS SNI, HTTP Host and DNS answers, and sends one-second summaries to this app over XPC. It requires the Network Extension entitlement (content-filter-provider-systemextension) on a paid developer team, and the app must be in the Applications folder. It only refuses a connection where a rule says to; with no enforcing rule it reports and nothing else."))
- .font(.caption).foregroundStyle(.secondary)
+ Text(filterDescription)
+ .font(.caption).foregroundStyle(.secondary)
.prose()
.frame(maxWidth: .infinity, alignment: .leading)
CaptureFact(icon: "checkmark.seal", tint: FL.good, title: L("Captures short-lived eligible flows"),
detail: L("macOS calls the filter when an eligible connection opens, providing better coverage of short requests than periodic sampling. Traffic from before activation is not available, some system traffic is exempt from content filters, and byte counts come from the filter's statistics reports."))
+ #if APP_STORE
+ CaptureFact(icon: "info.circle", tint: .secondary, title: L("It keeps filtering after you quit Flowlight"),
+ detail: L("The embedded filter remains active until you select Disable. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit."))
+ #else
CaptureFact(icon: "info.circle", tint: .secondary, title: L("It keeps filtering after you quit Flowlight"),
detail: L("The system extension runs independently and remains active until you select Disable or Uninstall. While the Flowlight app is closed, summaries are buffered by the extension for later delivery, subject to its retention limit."))
+ #endif
CaptureFact(icon: "exclamationmark.triangle", tint: FL.warning,
title: L("macOS runs one content filter at a time"),
- detail: L("If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter installs and connects but is never asked to filter anything, so nothing appears. Use the sampler on those Macs."))
+ detail: contentFilterConflictDetail)
}
.padding(.horizontal, 12).padding(.bottom, 14)
}
+ private var contentFilterConflictDetail: String {
+ #if APP_STORE
+ return L("If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter cannot receive traffic until that filter is disabled.")
+ #else
+ return L("If a VPN or security agent already has that slot — Palo Alto Networks GlobalProtect, CrowdStrike Falcon and similar all use it — Flowlight's filter installs and connects but is never asked to filter anything, so nothing appears. Use the sampler on those Macs.")
+ #endif
+ }
+
/// What the sampler costs, under the sampler.
private var samplerNotes: some View {
VStack(alignment: .leading, spacing: 10) {
diff --git a/Flowlight/UI/CaptureWarning.swift b/Flowlight/UI/CaptureWarning.swift
index cb6e37c..401091a 100644
--- a/Flowlight/UI/CaptureWarning.swift
+++ b/Flowlight/UI/CaptureWarning.swift
@@ -101,11 +101,13 @@ struct CaptureWarningBanner: View {
Text(warning)
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
+ #if !APP_STORE
if showSwitch, monitor.mode == .networkExtension {
Button(L("Use the nettop Sampler")) { monitor.setMode(.nettop) }
.controlSize(.small)
.padding(.top, 2)
}
+ #endif
}
}
.padding(12)
diff --git a/Flowlight/UI/ContentView.swift b/Flowlight/UI/ContentView.swift
index 2233ec0..7b39db4 100644
--- a/Flowlight/UI/ContentView.swift
+++ b/Flowlight/UI/ContentView.swift
@@ -26,6 +26,7 @@ struct ContentView: View {
.tint(FL.accent)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 0) {
+ #if !APP_STORE
if let update = updater.pendingUpdate {
Button { openWindow(id: "update") } label: {
Label(L("Flowlight %@ is available", update.version), systemImage: "arrow.down.circle.fill")
@@ -39,6 +40,7 @@ struct ContentView: View {
.padding(.horizontal, 10)
.help(L("See what's new and download the update"))
}
+ #endif
FocusBar()
Button { nav.show(.capture) } label: {
HStack(alignment: .top, spacing: 8) {
@@ -68,7 +70,12 @@ struct ContentView: View {
case .reports: ReportsView()
case .alerts: AlertsView()
case .rules: RulesView()
- case .inspect: InspectView()
+ case .inspect:
+ #if APP_STORE
+ LiveView()
+ #else
+ InspectView()
+ #endif
case .ask: AskView()
case .devices: DevicesView()
case .coverage: CoverageView()
@@ -92,11 +99,14 @@ struct ContentView: View {
}
}
.id(localization.revision)
+ #if !APP_STORE
.onReceive(NotificationCenter.default.publisher(for: .flowlightOpenUpdate)) { _ in openWindow(id: "update") }
+ #endif
.background(WindowSizer())
.background(WindowVisibilityReporter { window, visible in
monitor.windowVisibility(window, isVisible: visible)
})
+ #if !APP_STORE
.onChange(of: updater.showWindow) { _, show in
if show { openWindow(id: "update"); updater.showWindow = false }
}
@@ -104,6 +114,7 @@ struct ContentView: View {
// A single Window scene, so several restored main windows don't compete to present it.
if show { openWindow(id: "capture-onboarding") } else if was { dismissWindow(id: "capture-onboarding") }
}
+ #endif
.alert(L("Error"), isPresented: Binding(get: { monitor.lastError != nil }, set: { if !$0 { monitor.lastError = nil } })) {
Button(L("OK")) { monitor.lastError = nil }
} message: {
diff --git a/Flowlight/UI/CoverageView.swift b/Flowlight/UI/CoverageView.swift
index f1f5e23..203657a 100644
--- a/Flowlight/UI/CoverageView.swift
+++ b/Flowlight/UI/CoverageView.swift
@@ -85,7 +85,14 @@ private struct CoverageContent: View {
private var captureTitle: String {
switch monitor.mode {
case .networkExtension:
- return monitor.extensionFellBack ? L("Sampling with nettop, not the filter") : L("The filter sees connections as they open")
+ if monitor.extensionFellBack {
+ #if APP_STORE
+ return L("The content filter is unavailable")
+ #else
+ return L("Sampling with nettop, not the filter")
+ #endif
+ }
+ return L("The filter sees connections as they open")
case .nettop:
return L("Sampling with nettop every second")
}
@@ -96,7 +103,11 @@ private struct CoverageContent: View {
case .networkExtension where !monitor.extensionFellBack:
return L("Every eligible TCP and UDP flow is attributed as it opens, including connections too short for a sampler to catch.")
case .networkExtension:
+ #if APP_STORE
+ return L("The filter isn't answering, so Flowlight is not receiving traffic. Enable it under Content filter, then press Refresh.")
+ #else
return L("The filter isn't answering, so these figures come from the sampler: a connection that opens and closes between two readings is missing entirely, and no percentage below can show it.")
+ #endif
case .nettop:
return L("Byte counts are exact, but a connection that opens and closes between two readings is never recorded — a quick DNS lookup, a fast API call, a script that runs curl and exits. Only the filter sees those.")
}
diff --git a/Flowlight/UI/InspectMenuItems.swift b/Flowlight/UI/InspectMenuItems.swift
index a7a023d..5828cd6 100644
--- a/Flowlight/UI/InspectMenuItems.swift
+++ b/Flowlight/UI/InspectMenuItems.swift
@@ -15,6 +15,9 @@ struct InspectMenuItems: View {
var host: String?
var body: some View {
+ #if APP_STORE
+ EmptyView()
+ #else
// These scope the list rather than search it. The first version put the name in the search field, which
// also searches response bodies — so "Inspect Claude Code's Traffic" returned every GitHub page whose
// HTML contains those two words, and the app whose traffic you asked for was nowhere in it.
@@ -28,6 +31,7 @@ struct InspectMenuItems: View {
Button(L("Inspect %@'s Traffic", app.name)) { nav.showInspect(app: app) }
.help(helpText)
}
+ #endif
}
/// Says what will happen rather than promising what will be there. Inspect can only show what was
diff --git a/Flowlight/UI/ReportsView.swift b/Flowlight/UI/ReportsView.swift
index 3f007fd..c1e9a3a 100644
--- a/Flowlight/UI/ReportsView.swift
+++ b/Flowlight/UI/ReportsView.swift
@@ -235,9 +235,11 @@ struct ReportsView: View {
}
.toggleStyle(.switch)
.disabled(!LocalRiskSettings.canAnalyze)
+ #if !APP_STORE
if LocalRiskSettings.isActive && localRiskCounts.totalPotentialHarm > 0 {
Button(L("Show in Inspect")) { nav.showInspect(search: "", potentialHarmOnly: true) }
}
+ #endif
}
.padding(10)
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 10))
diff --git a/Flowlight/UI/SettingsView.swift b/Flowlight/UI/SettingsView.swift
index 32140b2..f623555 100644
--- a/Flowlight/UI/SettingsView.swift
+++ b/Flowlight/UI/SettingsView.swift
@@ -47,7 +47,9 @@ struct SettingsView: View {
}
Toggle(L("Show live rates in the menu bar"), isOn: $menuBarRates)
Toggle(L("Show notifications for warnings"), isOn: $notifications)
+ #if !APP_STORE
UpdateSettingsRow()
+ #endif
}
.formStyle(.grouped)
.frame(height: 340)
@@ -108,8 +110,10 @@ struct SettingsView: View {
.frame(height: 150)
.tabItem { Label(L("Storage"), systemImage: "internaldrive") }
+ #if !APP_STORE
PluginSettingsView()
.tabItem { Label(L("Plugins"), systemImage: "puzzlepiece.extension") }
+ #endif
ExportSettingsTab()
.tabItem { Label(L("Export"), systemImage: "arrow.up.forward.square") }
@@ -174,6 +178,7 @@ struct MenuBarContent: View {
NSApp.activate()
}
}
+ #if !APP_STORE
if let update = updater.pendingUpdate {
Button(L("Update Available: Flowlight %@…", update.version)) {
openWindow(id: "update")
@@ -182,6 +187,7 @@ struct MenuBarContent: View {
} else {
CheckForUpdatesButton()
}
+ #endif
Button(L("Quit")) { NSApp.terminate(nil) }.keyboardShortcut("q")
}
}
diff --git a/FlowlightExtension/FilterDataProvider.swift b/FlowlightExtension/FilterDataProvider.swift
index 2ee2bf9..0bdaa35 100644
--- a/FlowlightExtension/FilterDataProvider.swift
+++ b/FlowlightExtension/FilterDataProvider.swift
@@ -19,6 +19,9 @@ final class FilterDataProvider: NEFilterDataProvider {
static let filterStarted = OSAllocatedUnfairLock(initialState: (running: false, detail: ""))
override func startFilter(completionHandler: @escaping (Error?) -> Void) {
+ #if APP_STORE
+ IPCServer.shared.startListener()
+ #endif
let anyNetwork = NENetworkRule(remoteNetworkEndpoint: nil, remotePrefix: 0, localNetworkEndpoint: nil, localPrefix: 0,
protocol: .any, direction: .any)
let settings = NEFilterSettings(rules: [NEFilterRule(networkRule: anyNetwork, action: .filterData)],
@@ -40,6 +43,9 @@ final class FilterDataProvider: NEFilterDataProvider {
extensionLog.info("Filter stopped: \(reason.rawValue)")
flushTimer?.cancel()
flushTimer = nil
+ let batches = tracker.aggregator.drain(before: .max)
+ if !batches.isEmpty { IPCServer.shared.send(batches) }
+ Self.filterStarted.withLock { $0 = (false, "") }
completionHandler()
}
diff --git a/FlowlightExtension/FlowlightExtension-AppStore.entitlements b/FlowlightExtension/FlowlightExtension-AppStore.entitlements
new file mode 100644
index 0000000..937bc21
--- /dev/null
+++ b/FlowlightExtension/FlowlightExtension-AppStore.entitlements
@@ -0,0 +1,20 @@
+
+
+
+
+ com.apple.developer.networking.networkextension
+
+ content-filter-provider
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ $(TeamIdentifierPrefix)com.flowlight.shared
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
+
+
diff --git a/FlowlightExtension/IPCServer.swift b/FlowlightExtension/IPCServer.swift
index 5538aa6..acccc17 100644
--- a/FlowlightExtension/IPCServer.swift
+++ b/FlowlightExtension/IPCServer.swift
@@ -77,6 +77,9 @@ final class IPCServer: NSObject, NSXPCListenerDelegate, FlowlightProviderXPC, @u
}
func startListener() {
+ #if APP_STORE
+ guard listener == nil else { return }
+ #endif
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) }
diff --git a/FlowlightExtension/Info-AppStore.plist b/FlowlightExtension/Info-AppStore.plist
new file mode 100644
index 0000000..d4919a1
--- /dev/null
+++ b/FlowlightExtension/Info-AppStore.plist
@@ -0,0 +1,40 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ Flowlight Filter
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ FLAppGroupIdentifier
+ $(TeamIdentifierPrefix)com.flowlight.shared
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.networkextension.filter-data
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).FilterDataProvider
+
+ NetworkExtension
+
+ NEMachServiceName
+ $(TeamIdentifierPrefix)com.flowlight.shared.xpc
+
+
+
diff --git a/FlowlightExtension/main.swift b/FlowlightExtension/main.swift
index 0d6f20d..f1f64f3 100644
--- a/FlowlightExtension/main.swift
+++ b/FlowlightExtension/main.swift
@@ -1,3 +1,4 @@
+#if !APP_STORE
import Foundation
import NetworkExtension
@@ -7,3 +8,4 @@ autoreleasepool {
}
dispatchMain()
+#endif
diff --git a/FlowlightTests/ExtensionSourceTests.swift b/FlowlightTests/ExtensionSourceTests.swift
index cea4353..73a3f2e 100644
--- a/FlowlightTests/ExtensionSourceTests.swift
+++ b/FlowlightTests/ExtensionSourceTests.swift
@@ -62,21 +62,21 @@ final class ExtensionSourceTests: XCTestCase {
}
func testEveryPackMutationRequiresTheRegisteredXPCClient() throws {
- let root = URL(fileURLWithPath: #filePath)
- .deletingLastPathComponent()
- .deletingLastPathComponent()
- let source = try String(
- contentsOf: root.appendingPathComponent("FlowlightExtension/IPCServer.swift"),
- encoding: .utf8
- )
- for method in ["claimRule", "rulePackHello", "beginRulePack", "appendRulePack", "commitRulePack", "clearRulePacks"] {
+ let sourceURL = try XCTUnwrap(Bundle(for: Self.self).url(forResource: "IPCServer", withExtension: "txt"))
+ let source = try String(contentsOf: sourceURL, encoding: .utf8)
+ XCTAssertTrue(source.contains("func claimRule(payload: Data"), "the bundled fixture must be the live IPC source")
+ let methods = ["claimRule", "rulePackHello", "beginRulePack", "appendRulePack", "commitRulePack", "clearRulePacks"]
+ for method in methods {
let start = try XCTUnwrap(source.range(of: "func \(method)("))
- let body = source[start.lowerBound...].prefix(1_500)
+ let remaining = source[start.lowerBound...]
+ let next = remaining.dropFirst().range(of: "\n func ")?.lowerBound ?? remaining.endIndex
+ let body = remaining[..&2
+ exit 1
+}
+
+[ -x "$app_binary" ] || fail "missing app executable"
+[ -d "$extension" ] || fail "missing embedded content-filter app extension"
+[ ! -d "$contents/Library/SystemExtensions" ] || fail "contains a direct-distribution system extension"
+[ ! -e "$contents/Library/Helpers/FlowlightResponseScriptHelper" ] || fail "contains the response-script helper"
+[ ! -e "$contents/Resources/bpf-access.sh" ] || fail "contains the privileged packet-capture installer"
+
+point=$(/usr/libexec/PlistBuddy -c 'Print :NSExtension:NSExtensionPointIdentifier' "$extension_info")
+[ "$point" = "com.apple.networkextension.filter-data" ] || fail "wrong extension point: $point"
+
+app_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$contents/Info.plist")
+extension_version=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$extension_info")
+[ "$app_version" = "$extension_version" ] || fail "app and extension versions differ"
+
+app_build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$contents/Info.plist")
+extension_build=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$extension_info")
+[ "$app_build" = "$extension_build" ] || fail "app and extension builds differ"
+
+if otool -L "$app_binary" | grep -q SystemExtensions.framework; then
+ fail "links SystemExtensions.framework"
+fi
+if nm -u "$app_binary" | grep -q OSSystemExtension; then
+ fail "references OSSystemExtension symbols"
+fi
+
+printf 'Mac App Store product is structurally valid: Flowlight %s/%s\n' "$app_version" "$app_build"