From fdadc04df8abd0496147ced81fafbb08878fe066 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Thu, 22 Oct 2026 21:07:09 -0700 Subject: [PATCH 01/13] Restore safe manual Pages deployment Co-Authored-By: Claude Code --- .github/workflows/pages.yml | 21 +++++++++++++++++++-- scripts/check_site.py | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 00b7f2d..85a2118 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -10,6 +10,7 @@ on: push: branches: [main] paths: ["docs/**", ".github/workflows/pages.yml"] + workflow_dispatch: permissions: contents: read @@ -24,6 +25,8 @@ concurrency: jobs: deploy: + # A manual run is recovery for the deployed site, never a way to publish an arbitrary branch. + if: ${{ github.event_name == 'push' || github.ref == 'refs/heads/main' }} # Pinned rather than `ubuntu-latest`: that label moves to Ubuntu 26 in October 2026, and every run has # been carrying an annotation saying so. A named image means the move happens when we make it, with a # green run to prove it, rather than on a date somebody else chose. macOS is already pinned the same way. @@ -45,13 +48,27 @@ jobs: # guarantee that nothing broken is ever served. - name: Verify the official rule-pack catalog env: + # 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 }} + LIVE_CATALOG_URL: https://flowlight.xinbetween.com/packs/v1/official.json run: | python3 -I scripts/rule_pack_catalog.py compare \ rule-packs/official-source.json site/packs/v1/official.json - if git cat-file -e "$PREVIOUS_CATALOG_REF:site/packs/v1/official.json" 2>/dev/null; then + if [ "${{ github.event_name }}" = "push" ]; then + if git cat-file -e "$PREVIOUS_CATALOG_REF:site/packs/v1/official.json" 2>/dev/null; then + python3 -I scripts/rule_pack_catalog.py compare-previous site/packs/v1/official.json \ + --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" \ + --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-ref "$PREVIOUS_CATALOG_REF" + --previous "$LIVE_CATALOG" fi APP_VERSION=$(sed -n 's/.*MARKETING_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml) test -n "$APP_VERSION" diff --git a/scripts/check_site.py b/scripts/check_site.py index 4628f77..47568e0 100644 --- a/scripts/check_site.py +++ b/scripts/check_site.py @@ -23,6 +23,7 @@ OFFICIAL_PACK_PUBLIC_KEY = ROOT / "Flowlight/Resources/RulePacks/official-rule-packs-v1-public.pem" OFFICIAL_PACK_KEY_ID = "flowlight-rule-packs-2026-01" OFFICIAL_PACK_URL = "https://flowlight.xinbetween.com/packs/v1/official.json" +PAGES_WORKFLOW = ROOT / ".github/workflows/pages.yml" PROJECT = (ROOT / "project.yml").read_text() APP_VERSION = re.search(r'MARKETING_VERSION:\s*"([^"]+)"', PROJECT).group(1) @@ -363,6 +364,25 @@ def entries(text): if re.search(r"\b(setInterval|setTimeout|requestAnimationFrame)\s*\(", source_text): found.append(f"{source.relative_to(ROOT)}: home page has recurring JavaScript animation") + # 16. Pages occasionally needs redeploying without changing public bytes. A manual dispatch has no `before` + # SHA, so it must compare with the signed catalog already being served rather than a guessed commit. Keep these + # contracts scoped to the trigger, deploy job, and catalog-verification step so duplicate text elsewhere cannot + # make a broken workflow look safe. + pages_workflow = PAGES_WORKFLOW.read_text() + trigger = pages_workflow.split("\npermissions:", 1)[0] + 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 "" + 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") + return found From 95028239bbeb7fee883188a522cda123a9e4edd6 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Thu, 22 Oct 2026 21:51:27 -0700 Subject: [PATCH 02/13] Surface changed packs that need review Co-Authored-By: Claude Code --- Flowlight/Analysis/RulePackStore.swift | 1 + Flowlight/App/AppNavigation.swift | 34 +++++++++++++- Flowlight/App/DemoData.swift | 11 +++++ Flowlight/App/FlowlightApp.swift | 4 +- Flowlight/App/TrafficMonitor.swift | 3 ++ .../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/UI/AgentsView.swift | 4 +- Flowlight/UI/AskView.swift | 2 +- Flowlight/UI/ContentView.swift | 45 +++++++++++++++++-- Flowlight/UI/LiveView.swift | 2 +- Flowlight/UI/MCPDomainsSection.swift | 2 +- Flowlight/UI/RulesView.swift | 23 +++------- Flowlight/UI/SettingsView.swift | 2 +- FlowlightTests/DemoDataTests.swift | 13 ++++++ FlowlightTests/InspectNavigationTests.swift | 33 +++++++++++++- FlowlightTests/RulePackTests.swift | 40 ++++++++++++++++- 25 files changed, 207 insertions(+), 32 deletions(-) diff --git a/Flowlight/Analysis/RulePackStore.swift b/Flowlight/Analysis/RulePackStore.swift index 944986e..6ca0ae4 100644 --- a/Flowlight/Analysis/RulePackStore.swift +++ b/Flowlight/Analysis/RulePackStore.swift @@ -93,6 +93,7 @@ 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 failureMessage: String? { if case .failed(let message) = status { return message } return nil diff --git a/Flowlight/App/AppNavigation.swift b/Flowlight/App/AppNavigation.swift index 908e7d7..0c337cf 100644 --- a/Flowlight/App/AppNavigation.swift +++ b/Flowlight/App/AppNavigation.swift @@ -108,6 +108,22 @@ enum SidebarSection: String, CaseIterable, Identifiable { var items: [SidebarItem] { SidebarItem.allCases.filter { $0.section == self } } } +/// The three views inside Rules. This lives with the shared navigation state so an app-wide notice can take someone +/// straight to a changed pack, while ordinary rule links always land back on the rule list. +enum RulesTab: String, CaseIterable, Identifiable { + case rules, packs, activity + + var id: String { rawValue } + + @MainActor var title: String { + switch self { + case .rules: return L("Rules") + case .packs: return L("Packs") + case .activity: return L("What they did") + } + } +} + /// A rule the user started writing somewhere else — a table row, an alert — waiting for the Rules screen to open /// its editor. The rule travels rather than its ingredients, so every "Block this" writes the same object. struct RuleRequest: Equatable { @@ -126,15 +142,31 @@ struct ReportRequest: Equatable { /// Shared navigation state so screens can link to each other (Live/Alerts → Reports). @MainActor final class AppNavigation: ObservableObject { - @Published var selection: SidebarItem = SidebarItem(rawValue: UserDefaults.standard.string(forKey: "FLScreen") ?? "") ?? .live + @Published private(set) var selection: SidebarItem = SidebarItem(rawValue: UserDefaults.standard.string(forKey: "FLScreen") ?? "") ?? .live + @Published var rulesTab: RulesTab = .rules @Published var reportRequest: ReportRequest? @Published var inspectRequest: InspectRequest? @Published var ruleRequest: RuleRequest? /// 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) { + if item == .rules { rulesTab = .rules } + selection = item + } + func writeRule(_ rule: Rule) { ruleRequest = RuleRequest(rule: rule) + showRules() + } + + func showRules() { + rulesTab = .rules + selection = .rules + } + + func showPackReviews() { + rulesTab = .packs selection = .rules } diff --git a/Flowlight/App/DemoData.swift b/Flowlight/App/DemoData.swift index e988bef..ec111b9 100644 --- a/Flowlight/App/DemoData.swift +++ b/Flowlight/App/DemoData.swift @@ -5,6 +5,7 @@ import Foundation /// real service is depicted as malicious. enum DemoData { static var isEnabled: Bool { UserDefaults.standard.bool(forKey: "FLDemo") } + static var showsPackReview: Bool { UserDefaults.standard.bool(forKey: "FLDemoPackReview") } static var databaseURL: URL { let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Flowlight") @@ -219,6 +220,16 @@ enum DemoData { try seedInspection(db, now: now) } + /// A previously accepted pack whose definition has since changed. Screenshot and acceptance runs use this + /// disposable state to exercise the same review affordance a catalog update produces, without touching real data. + static func seedPackReview(_ db: TrafficDatabase) throws { + let definition = BuiltInRulePacks.advertising + var configuration = RulePackConfiguration(packID: definition.id) + configuration.consentedVersion = "previous-demo-version" + configuration.consentedContentSHA256 = String(repeating: "0", count: 64) + try db.saveRulePackConfiguration(configuration) + } + private static func seedAlerts(_ db: TrafficDatabase, now: Date) throws { typealias K = AnomalyEngine.Kind let items: [(Int, K, String, String, String, Int)] = [ diff --git a/Flowlight/App/FlowlightApp.swift b/Flowlight/App/FlowlightApp.swift index ea8cec2..f66a787 100644 --- a/Flowlight/App/FlowlightApp.swift +++ b/Flowlight/App/FlowlightApp.swift @@ -108,7 +108,7 @@ struct FlowlightApp: App { var body: some Scene { WindowGroup("Flowlight", id: "main") { - ContentView() + ContentView(rulePacks: monitor.rulePacks) .environmentObject(monitor) .environmentObject(extensionManager) .environmentObject(nav) @@ -143,7 +143,7 @@ struct FlowlightApp: App { } CommandGroup(before: .sidebar) { ForEach(SidebarItem.allCases) { item in - Button(item.title) { nav.selection = item } + Button(item.title) { nav.show(item) } .keyboardShortcut(item.shortcut ?? .init(" "), modifiers: item.shortcut == nil ? [] : .command) } Divider() diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift index 2c5f152..f611263 100644 --- a/Flowlight/App/TrafficMonitor.swift +++ b/Flowlight/App/TrafficMonitor.swift @@ -131,6 +131,9 @@ final class TrafficMonitor: ObservableObject { db = try! TrafficDatabase(url: tmp) lastError = L("Could not open database: %@", "\(error)") } + // 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) } 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 e196cd3..b4b900a 100644 --- a/Flowlight/Localization/de.lproj/Localizable.strings +++ b/Flowlight/Localization/de.lproj/Localizable.strings @@ -1188,6 +1188,8 @@ "Blocking" = "Blockieren"; "Needs extension update" = "Erweiterung muss aktualisiert werden"; "Needs review" = "Überprüfung erforderlich"; +"One pack needs review" = "Ein Paket muss überprüft werden"; +"%lld packs need review" = "%lld Pakete müssen überprüft werden"; "Test against recent traffic" = "Mit kürzlich erfasstem Datenverkehr testen"; "Apply this pack to every app?" = "Dieses Paket auf jede App anwenden?"; "Apply to every app" = "Auf jede App anwenden"; diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings index 76c31ba..6d654e9 100644 --- a/Flowlight/Localization/en.lproj/Localizable.strings +++ b/Flowlight/Localization/en.lproj/Localizable.strings @@ -1120,6 +1120,8 @@ "Blocking" = "Blocking"; "Needs extension update" = "Needs extension update"; "Needs review" = "Needs review"; +"One pack needs review" = "One pack needs review"; +"%lld packs need review" = "%lld packs need review"; "Test against recent traffic" = "Test against recent traffic"; "Apply this pack to every app?" = "Apply this pack to every app?"; "Apply to every app" = "Apply to every app"; diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings index 0337a9f..b9deeea 100644 --- a/Flowlight/Localization/es.lproj/Localizable.strings +++ b/Flowlight/Localization/es.lproj/Localizable.strings @@ -1188,6 +1188,8 @@ "Blocking" = "Bloqueando"; "Needs extension update" = "Necesita actualizar la extensión"; "Needs review" = "Necesita revisión"; +"One pack needs review" = "Un paquete necesita revisión"; +"%lld packs need review" = "%lld paquetes necesitan revisión"; "Test against recent traffic" = "Probar con el tráfico reciente"; "Apply this pack to every app?" = "¿Aplicar este paquete a todas las apps?"; "Apply to every app" = "Aplicar a todas las apps"; diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings index f4b1118..f3b697a 100644 --- a/Flowlight/Localization/fr.lproj/Localizable.strings +++ b/Flowlight/Localization/fr.lproj/Localizable.strings @@ -1188,6 +1188,8 @@ "Blocking" = "Blocage"; "Needs extension update" = "Mise à jour de l’extension requise"; "Needs review" = "Examen requis"; +"One pack needs review" = "Un pack doit être examiné"; +"%lld packs need review" = "%lld packs doivent être examinés"; "Test against recent traffic" = "Tester sur le trafic récent"; "Apply this pack to every app?" = "Appliquer ce pack à toutes les apps ?"; "Apply to every app" = "Appliquer à toutes les apps"; diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings index c23db4c..2996fb8 100644 --- a/Flowlight/Localization/it.lproj/Localizable.strings +++ b/Flowlight/Localization/it.lproj/Localizable.strings @@ -1188,6 +1188,8 @@ "Blocking" = "Blocco"; "Needs extension update" = "Richiede l’aggiornamento dell’estensione"; "Needs review" = "Richiede una verifica"; +"One pack needs review" = "Un pacchetto richiede una verifica"; +"%lld packs need review" = "%lld pacchetti richiedono una verifica"; "Test against recent traffic" = "Prova sul traffico recente"; "Apply this pack to every app?" = "Applicare questo pacchetto a tutte le app?"; "Apply to every app" = "Applica a tutte le app"; diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings index 3a47e31..5812f45 100644 --- a/Flowlight/Localization/ja.lproj/Localizable.strings +++ b/Flowlight/Localization/ja.lproj/Localizable.strings @@ -1188,6 +1188,8 @@ "Blocking" = "ブロック中"; "Needs extension update" = "拡張機能の更新が必要"; "Needs review" = "確認が必要"; +"One pack needs review" = "1個のパックを確認する必要があります"; +"%lld packs need review" = "%lld個のパックを確認する必要があります"; "Test against recent traffic" = "最近のトラフィックでテスト"; "Apply this pack to every app?" = "このパックをすべてのアプリに適用しますか?"; "Apply to every app" = "すべてのアプリに適用"; diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings index ae457c6..7302968 100644 --- a/Flowlight/Localization/ko.lproj/Localizable.strings +++ b/Flowlight/Localization/ko.lproj/Localizable.strings @@ -1211,6 +1211,8 @@ "Blocking" = "차단 중"; "Needs extension update" = "확장 프로그램 업데이트 필요"; "Needs review" = "검토 필요"; +"One pack needs review" = "팩 1개를 검토해야 합니다"; +"%lld packs need review" = "팩 %lld개를 검토해야 합니다"; "Test against recent traffic" = "최근 트래픽으로 테스트"; "Apply this pack to every app?" = "이 팩을 모든 앱에 적용할까요?"; "Apply to every app" = "모든 앱에 적용"; diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings index 6e2dd1f..7409676 100644 --- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings +++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings @@ -1211,6 +1211,8 @@ "Blocking" = "A bloquear"; "Needs extension update" = "Precisa de atualizar a extensão"; "Needs review" = "Precisa de revisão"; +"One pack needs review" = "Um pacote precisa de revisão"; +"%lld packs need review" = "%lld pacotes precisam de revisão"; "Test against recent traffic" = "Testar com o tráfego recente"; "Apply this pack to every app?" = "Aplicar este pacote a todas as apps?"; "Apply to every app" = "Aplicar a todas as apps"; diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings index 7971eba..2429ac2 100644 --- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings @@ -1211,6 +1211,8 @@ "Blocking" = "正在阻止"; "Needs extension update" = "需要更新扩展"; "Needs review" = "需要检查"; +"One pack needs review" = "有 1 个包需要检查"; +"%lld packs need review" = "有 %lld 个包需要检查"; "Test against recent traffic" = "用最近的流量测试"; "Apply this pack to every app?" = "将此包应用于所有应用?"; "Apply to every app" = "应用于所有应用"; diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings index ec4ca33..5ef0bf2 100644 --- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings @@ -1211,6 +1211,8 @@ "Blocking" = "正在封鎖"; "Needs extension update" = "需要更新延伸功能"; "Needs review" = "需要檢視"; +"One pack needs review" = "有 1 個套件需要檢視"; +"%lld packs need review" = "有 %lld 個套件需要檢視"; "Test against recent traffic" = "使用最近的流量測試"; "Apply this pack to every app?" = "將此套件套用到所有 App?"; "Apply to every app" = "套用到所有 App"; diff --git a/Flowlight/UI/AgentsView.swift b/Flowlight/UI/AgentsView.swift index 6599400..4c96ebd 100644 --- a/Flowlight/UI/AgentsView.swift +++ b/Flowlight/UI/AgentsView.swift @@ -1215,7 +1215,7 @@ struct InspectionHint: View { Text(message) .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) if !inspecting { - Button(L("Set up HTTPS inspection")) { nav.selection = .inspect } + Button(L("Set up HTTPS inspection")) { nav.show(.inspect) } .controlSize(.small) } else if !seen { HStack(spacing: 8) { @@ -1229,7 +1229,7 @@ struct InspectionHint: View { Button(L("Relaunch Through the Proxy")) { confirmingRelaunch = true } .controlSize(.small) } - Button(L("Open Inspected Terminal")) { nav.selection = .inspect } + Button(L("Open Inspected Terminal")) { nav.show(.inspect) } .controlSize(.small) } if let relaunchError { diff --git a/Flowlight/UI/AskView.swift b/Flowlight/UI/AskView.swift index 442d64d..b2791fd 100644 --- a/Flowlight/UI/AskView.swift +++ b/Flowlight/UI/AskView.swift @@ -76,7 +76,7 @@ private struct AskContent: View { guard let filter = call.filter else { return } nav.showReport(filter: filter, end: call.to) } go: { screen in - nav.selection = screen + nav.show(screen) } .entrance() if turn.id != ask.turns.last?.id { Divider() } diff --git a/Flowlight/UI/ContentView.swift b/Flowlight/UI/ContentView.swift index 6d305c3..2233ec0 100644 --- a/Flowlight/UI/ContentView.swift +++ b/Flowlight/UI/ContentView.swift @@ -2,6 +2,7 @@ import SwiftUI struct ContentView: View { @ObservedObject private var localization = Localization.shared + @ObservedObject var rulePacks: RulePackStore @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var nav: AppNavigation @EnvironmentObject var updater: UpdateChecker @@ -10,12 +11,12 @@ struct ContentView: View { var body: some View { NavigationSplitView { - List(selection: Binding(get: { nav.selection }, set: { if let v = $0 { nav.selection = v } })) { + List(selection: Binding(get: { nav.selection }, set: { if let value = $0 { nav.show(value) } })) { ForEach(SidebarSection.allCases) { section in Section(section.title) { ForEach(section.items) { item in Label(item.title, systemImage: item.icon) - .badge(item == .alerts ? monitor.unacknowledgedAlerts : 0) + .badge(badge(for: item)) .tag(item) } } @@ -39,7 +40,7 @@ struct ContentView: View { .help(L("See what's new and download the update")) } FocusBar() - Button { nav.selection = .capture } label: { + Button { nav.show(.capture) } label: { HStack(alignment: .top, spacing: 8) { LiveDot(color: monitor.isReceiving ? FL.good : FL.warning, active: monitor.isReceiving) .padding(.top, 4) @@ -74,6 +75,10 @@ struct ContentView: View { case .capture: CaptureView() } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .safeAreaInset(edge: .top, spacing: 0) { + if rulePacks.pendingReviewCount > 0 { packReviewBanner } + } // One help button for all nine screens, because the documentation is grouped the way this sidebar // is: whatever is on screen, the button opens the section about it rather than the top of the page. .toolbar { @@ -105,4 +110,38 @@ struct ContentView: View { Text(monitor.lastError ?? "") } } + + private func badge(for item: SidebarItem) -> Int { + switch item { + case .alerts: return monitor.unacknowledgedAlerts + case .rules: return rulePacks.pendingReviewCount + default: return 0 + } + } + + private var packReviewBanner: some View { + Button { nav.showPackReviews() } label: { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(FL.warning) + Text(rulePacks.pendingReviewCount == 1 + ? L("One pack needs review") + : L("%lld packs need review", rulePacks.pendingReviewCount)) + Spacer(minLength: 8) + Text(L("Review…")) + .foregroundStyle(FL.accent) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } + .font(.callout) + .padding(.horizontal, 16).padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background(.bar) + .overlay(alignment: .bottom) { Divider() } + } + .buttonStyle(.plain) + .help(L("Review…")) + } } diff --git a/Flowlight/UI/LiveView.swift b/Flowlight/UI/LiveView.swift index a057631..34e2068 100644 --- a/Flowlight/UI/LiveView.swift +++ b/Flowlight/UI/LiveView.swift @@ -108,7 +108,7 @@ struct LiveView: View { Text(monitor.status) } } actions: { - Button(L("Open Capture Settings")) { nav.selection = .capture } + Button(L("Open Capture Settings")) { nav.show(.capture) } } } } diff --git a/Flowlight/UI/MCPDomainsSection.swift b/Flowlight/UI/MCPDomainsSection.swift index 649b0a0..8de93af 100644 --- a/Flowlight/UI/MCPDomainsSection.swift +++ b/Flowlight/UI/MCPDomainsSection.swift @@ -82,6 +82,6 @@ struct MCPDomainsSection: View { name: name)) // Straight to the rule, because the next question is always "what would that have stopped?" — which // 0.9.4's simulation answers in the editor. - nav.selection = .rules + nav.showRules() } } diff --git a/Flowlight/UI/RulesView.swift b/Flowlight/UI/RulesView.swift index 16ba75c..a525a66 100644 --- a/Flowlight/UI/RulesView.swift +++ b/Flowlight/UI/RulesView.swift @@ -10,7 +10,6 @@ struct RulesView: View { @EnvironmentObject var nav: AppNavigation @ObservedObject private var templates = RuleTemplateRequests.shared @State private var sheet: Sheet? - @State private var tab = Tab.rules @State private var now = Date() /// One sheet at a time, named rather than implied by two booleans — the editor and the library are alternatives @@ -27,23 +26,11 @@ struct RulesView: View { } } - private enum Tab: String, CaseIterable, Identifiable { - case rules, packs, activity - var id: String { rawValue } - var title: String { - switch self { - case .rules: return L("Rules") - case .packs: return L("Packs") - case .activity: return L("What they did") - } - } - } - private var store: RuleStore { monitor.rules } var body: some View { Group { - switch tab { + switch nav.rulesTab { case .rules: if store.rules.isEmpty { empty } else { ruleList } case .packs: @@ -111,15 +98,15 @@ struct RulesView: View { // Nothing to switch between until there is something in one of them. if !store.rules.isEmpty || !store.events.isEmpty || !monitor.rulePacks.definitions.isEmpty { ToolbarItem(placement: .principal) { - Picker("", selection: $tab) { - ForEach(Tab.allCases) { Text($0.title).tag($0) } + Picker("", selection: $nav.rulesTab) { + ForEach(RulesTab.allCases) { Text($0.title).tag($0) } } .pickerStyle(.segmented).labelsHidden().frame(width: 310) } } ToolbarItemGroup { pauseControl - if tab == .rules { + if nav.rulesTab == .rules { Button { sheet = .library(nil, RuleTemplate.Subject()) } label: { @@ -670,7 +657,7 @@ struct RuleMenuItems: View { if let rule = template.rules(for: subject).first { nav.writeRule(rule) } case .list: RuleTemplateRequests.shared.open(template, for: subject) - nav.selection = .rules + nav.showRules() } } diff --git a/Flowlight/UI/SettingsView.swift b/Flowlight/UI/SettingsView.swift index ba33890..32140b2 100644 --- a/Flowlight/UI/SettingsView.swift +++ b/Flowlight/UI/SettingsView.swift @@ -169,7 +169,7 @@ struct MenuBarContent: View { } if monitor.unacknowledgedAlerts > 0 { Button(L("Review Alerts…")) { - nav.selection = .alerts + nav.show(.alerts) openWindow(id: "main") NSApp.activate() } diff --git a/FlowlightTests/DemoDataTests.swift b/FlowlightTests/DemoDataTests.swift index 03e869f..00dc9b5 100644 --- a/FlowlightTests/DemoDataTests.swift +++ b/FlowlightTests/DemoDataTests.swift @@ -16,4 +16,17 @@ final class DemoDataTests: XCTestCase { XCTAssertFalse(day.isEmpty, "the last day has no hourly rollups, so every view that reads history is empty") XCTAssertTrue(day.contains { !$0.bundleID.isEmpty }, "rollups exist but name no app") } + + func testPackReviewFixtureWritesStaleConsentWithoutEnablingThePack() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("demo-pack-review-\(UUID()).sqlite") + defer { try? FileManager.default.removeItem(at: url) } + let db = try TrafficDatabase(url: url) + + try DemoData.seedPackReview(db) + + let saved = try XCTUnwrap(db.loadRulePackConfigurations().first) + XCTAssertEqual(saved.packID, BuiltInRulePacks.advertising.id) + XCTAssertFalse(saved.enabled) + XCTAssertFalse(saved.isConsented(to: BuiltInRulePacks.advertising)) + } } diff --git a/FlowlightTests/InspectNavigationTests.swift b/FlowlightTests/InspectNavigationTests.swift index 06e369d..ec765c9 100644 --- a/FlowlightTests/InspectNavigationTests.swift +++ b/FlowlightTests/InspectNavigationTests.swift @@ -5,7 +5,7 @@ import XCTest final class InspectNavigationTests: XCTestCase { func testAskingForInspectSwitchesScreenAndCarriesTheSubject() { let nav = AppNavigation() - nav.selection = .live + nav.show(.live) nav.showInspect(search: "api.anthropic.com") XCTAssertEqual(nav.selection, .inspect) XCTAssertEqual(nav.inspectRequest?.search, "api.anthropic.com") @@ -29,4 +29,35 @@ final class InspectNavigationTests: XCTestCase { XCTAssertTrue(SidebarItem.allCases.contains(.inspect)) XCTAssertEqual(SidebarItem.inspect.section, .investigate) } + + func testPackReviewNavigationOpensThePacksTab() { + let nav = AppNavigation() + nav.show(.live) + + nav.showPackReviews() + + XCTAssertEqual(nav.selection, .rules) + XCTAssertEqual(nav.rulesTab, .packs) + } + + func testOrdinaryRulesNavigationReturnsToTheRulesTab() { + let nav = AppNavigation() + nav.showPackReviews() + + nav.show(.rules) + + XCTAssertEqual(nav.selection, .rules) + XCTAssertEqual(nav.rulesTab, .rules) + } + + func testWritingRuleReturnsToTheRulesTab() { + let nav = AppNavigation() + nav.showPackReviews() + + nav.writeRule(Rule()) + + XCTAssertEqual(nav.selection, .rules) + XCTAssertEqual(nav.rulesTab, .rules) + XCTAssertNotNil(nav.ruleRequest) + } } diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift index fde6e89..84aa769 100644 --- a/FlowlightTests/RulePackTests.swift +++ b/FlowlightTests/RulePackTests.swift @@ -811,13 +811,51 @@ final class RulePackTests: XCTestCase { original.manifest.version = "2" original.manifest.entryCount = original.entries.count original.manifest.contentSHA256 = RulePackValidator.digest(original.entries) + let catalog = VerifiedRulePackCatalog( + definitions: [original], sequence: 2, digest: String(repeating: "2", count: 64), + signerKeyID: "test-key", stale: false, rawEnvelope: Data("replacement".utf8) + ) + _ = try db.acceptVerifiedRulePackCatalog( + catalog, checkedAt: Date(timeIntervalSince1970: 300), + nextEligibleAt: Date(timeIntervalSince1970: 400) + ) let store = RulePackStore(definitions: [original]) store.attach(db: db) - try await waitUntil { store.configurations.count == 1 } + try await waitUntil { store.configurations.count == 1 && store.reviewReceipt(for: original) != nil } XCTAssertFalse(store.configuration(for: original.id).enabled) XCTAssertTrue(store.needsReview(original)) + XCTAssertEqual(store.pendingReviewCount, 1) XCTAssertNil(store.snapshot) + + var renewed = store.configuration(for: original.id) + renewed.enabled = true + var result: RulePackStore.SaveResult? + store.save(renewed, reviewedDefinition: original, + receipt: try XCTUnwrap(store.reviewReceipt(for: original))) { result = $0 } + try await waitUntil { result != nil } + + XCTAssertEqual(result, .saved) + XCTAssertEqual(store.pendingReviewCount, 0) + XCTAssertNotNil(store.snapshot) + } + + @MainActor + func testPendingReviewCountIgnoresNeverConsentedAndCurrentConsent() async throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-pack-current-consent-\(UUID().uuidString).sqlite") + let db = try TrafficDatabase(url: url) + let definition = BuiltInRulePacks.advertising + var configuration = RulePackConfiguration(packID: definition.id) + configuration.consentedVersion = definition.manifest.version + configuration.consentedContentSHA256 = definition.manifest.contentSHA256 + try db.saveRulePackConfiguration(configuration) + + let store = RulePackStore() + store.attach(db: db) + try await waitUntil { store.configurations.count == store.definitions.count } + + XCTAssertEqual(store.pendingReviewCount, 0) } func testEditorReviewPreservesOffConfiguration() { From f75473129288a902305408df575cbb8f019d3474 Mon Sep 17 00:00:00 2001 From: blessdyb Date: Thu, 22 Oct 2026 21:55:31 -0700 Subject: [PATCH 03/13] Prepare Flowlight 0.15.5 Co-Authored-By: Claude Code --- docs/404.html | 2 +- docs/about/index.html | 2 +- docs/de/about/index.html | 2 +- docs/de/docs/index.html | 4 ++-- docs/de/index.html | 6 +++--- docs/de/privacy/index.html | 2 +- docs/de/threat-model/index.html | 2 +- docs/docs/index.html | 4 ++-- docs/es/about/index.html | 2 +- docs/es/docs/index.html | 4 ++-- docs/es/index.html | 6 +++--- docs/es/privacy/index.html | 2 +- docs/es/threat-model/index.html | 2 +- docs/fr/about/index.html | 2 +- docs/fr/docs/index.html | 4 ++-- docs/fr/index.html | 6 +++--- docs/fr/privacy/index.html | 2 +- docs/fr/threat-model/index.html | 2 +- docs/index.html | 6 +++--- docs/it/about/index.html | 2 +- docs/it/docs/index.html | 4 ++-- docs/it/index.html | 6 +++--- docs/it/privacy/index.html | 2 +- docs/it/threat-model/index.html | 2 +- docs/ja/about/index.html | 2 +- docs/ja/docs/index.html | 4 ++-- docs/ja/index.html | 6 +++--- docs/ja/privacy/index.html | 2 +- docs/ja/threat-model/index.html | 2 +- docs/ko/about/index.html | 2 +- docs/ko/docs/index.html | 4 ++-- docs/ko/index.html | 6 +++--- docs/ko/privacy/index.html | 2 +- docs/ko/threat-model/index.html | 2 +- docs/llms-full.txt | 17 ++++++++++++++--- docs/llms.txt | 2 +- docs/plugins/index.html | 2 +- docs/privacy/index.html | 2 +- docs/pt-PT/about/index.html | 2 +- docs/pt-PT/docs/index.html | 4 ++-- docs/pt-PT/index.html | 6 +++--- docs/pt-PT/privacy/index.html | 2 +- docs/pt-PT/threat-model/index.html | 2 +- docs/releases/index.html | 16 ++++++++++++++-- docs/threat-model/index.html | 2 +- docs/zh-Hans/about/index.html | 2 +- docs/zh-Hans/docs/index.html | 4 ++-- docs/zh-Hans/index.html | 6 +++--- docs/zh-Hans/privacy/index.html | 2 +- docs/zh-Hans/threat-model/index.html | 2 +- docs/zh-Hant/about/index.html | 2 +- docs/zh-Hant/docs/index.html | 4 ++-- docs/zh-Hant/index.html | 6 +++--- docs/zh-Hant/privacy/index.html | 2 +- docs/zh-Hant/threat-model/index.html | 2 +- project.yml | 4 ++-- site/pages/releases.html | 14 +++++++++++++- 57 files changed, 126 insertions(+), 91 deletions(-) diff --git a/docs/404.html b/docs/404.html index 385afde..fd3eef9 100644 --- a/docs/404.html +++ b/docs/404.html @@ -86,7 +86,7 @@

That page isn't here

Try the home page,

diff --git a/docs/about/index.html b/docs/about/index.html index fcbba1b..20d3739 100644 --- a/docs/about/index.html +++ b/docs/about/index.html @@ -153,7 +153,7 @@

Thanks

diff --git a/docs/de/about/index.html b/docs/de/about/index.html index 37b3910..3509703 100644 --- a/docs/de/about/index.html +++ b/docs/de/about/index.html @@ -152,7 +152,7 @@

Dank

diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html index 2fab58c..da801b3 100644 --- a/docs/de/docs/index.html +++ b/docs/de/docs/index.html @@ -76,7 +76,7 @@

Dokumentation

Flowlight benutzen

-

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

+

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

diff --git a/docs/de/index.html b/docs/de/index.html index bc4d5ea..ff75d1b 100644 --- a/docs/de/index.html +++ b/docs/de/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

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

-

v0.15.4macOS 15+UniversalGPL-3.0Keine Telemetrie

+

v0.15.5macOS 15+UniversalGPL-3.0Keine Telemetrie

@@ -572,7 +572,7 @@

Wisse, was deinen Mac verlässt.

diff --git a/docs/de/privacy/index.html b/docs/de/privacy/index.html index 94fb0d6..ce975f2 100644 --- a/docs/de/privacy/index.html +++ b/docs/de/privacy/index.html @@ -189,7 +189,7 @@

Kontakt

diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html index 48dbd7b..20d0e5b 100644 --- a/docs/de/threat-model/index.html +++ b/docs/de/threat-model/index.html @@ -225,7 +225,7 @@

Eine Schwachstelle melden

diff --git a/docs/docs/index.html b/docs/docs/index.html index 425b674..447fcea 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -76,7 +76,7 @@

Documentation

Using Flowlight

-

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

+

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

diff --git a/docs/es/about/index.html b/docs/es/about/index.html index 1981064..bc1eb62 100644 --- a/docs/es/about/index.html +++ b/docs/es/about/index.html @@ -152,7 +152,7 @@

Agradecimientos

diff --git a/docs/es/docs/index.html b/docs/es/docs/index.html index 897c640..efab8a0 100644 --- a/docs/es/docs/index.html +++ b/docs/es/docs/index.html @@ -76,7 +76,7 @@

Documentación

Usar Flowlight

-

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

+

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

diff --git a/docs/es/index.html b/docs/es/index.html index c42b1c2..6bd350e 100644 --- a/docs/es/index.html +++ b/docs/es/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

Observa la actividad de red de tus apps.

brew install --cask xinbetween/tap/flowlight

-

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

+

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

@@ -572,7 +572,7 @@

Ten claro qué sale de tu Mac.

diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html index 7d62b05..db5cb6d 100644 --- a/docs/es/privacy/index.html +++ b/docs/es/privacy/index.html @@ -189,7 +189,7 @@

Contacto

diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html index 1f1e7f5..9d30869 100644 --- a/docs/es/threat-model/index.html +++ b/docs/es/threat-model/index.html @@ -221,7 +221,7 @@

Informar de una vulnerabilidad

diff --git a/docs/fr/about/index.html b/docs/fr/about/index.html index 6020cd6..3422fc6 100644 --- a/docs/fr/about/index.html +++ b/docs/fr/about/index.html @@ -152,7 +152,7 @@

Remerciements

diff --git a/docs/fr/docs/index.html b/docs/fr/docs/index.html index 2bb3458..9e9b6b7 100644 --- a/docs/fr/docs/index.html +++ b/docs/fr/docs/index.html @@ -76,7 +76,7 @@

Documentation

Utiliser Flowlight

-

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

+

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

diff --git a/docs/fr/index.html b/docs/fr/index.html index a013e9f..fc3dda2 100644 --- a/docs/fr/index.html +++ b/docs/fr/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

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

brew install --cask xinbetween/tap/flowlight

-

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

+

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

@@ -572,7 +572,7 @@

Sachez ce qui quitte votre Mac.

diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html index 642647a..ebbf5a2 100644 --- a/docs/fr/privacy/index.html +++ b/docs/fr/privacy/index.html @@ -189,7 +189,7 @@

Contact

diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html index 69add44..dcf1a52 100644 --- a/docs/fr/threat-model/index.html +++ b/docs/fr/threat-model/index.html @@ -224,7 +224,7 @@

Signaler une vulnérabilité

diff --git a/docs/index.html b/docs/index.html index c8b7ce4..f921ef6 100644 --- a/docs/index.html +++ b/docs/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

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

-

v0.15.4macOS 15+UniversalGPL-3.0No telemetry

+

v0.15.5macOS 15+UniversalGPL-3.0No telemetry

@@ -594,7 +594,7 @@

Know what leaves your Mac.

diff --git a/docs/it/about/index.html b/docs/it/about/index.html index 9f874a7..fc17bb7 100644 --- a/docs/it/about/index.html +++ b/docs/it/about/index.html @@ -152,7 +152,7 @@

Ringraziamenti

diff --git a/docs/it/docs/index.html b/docs/it/docs/index.html index 5791a9d..08d8ef6 100644 --- a/docs/it/docs/index.html +++ b/docs/it/docs/index.html @@ -76,7 +76,7 @@

Documentazione

Usare Flowlight

-

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

+

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

diff --git a/docs/it/index.html b/docs/it/index.html index 253af04..dce4c12 100644 --- a/docs/it/index.html +++ b/docs/it/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

Vedi la rete delle tue app.
Ca Metti una stella su GitHub

brew install --cask xinbetween/tap/flowlight

-

v0.15.4macOS 15+UniversaleGPL-3.0Nessuna telemetria

+

v0.15.5macOS 15+UniversaleGPL-3.0Nessuna telemetria

@@ -572,7 +572,7 @@

Sappi che cosa esce dal tuo Mac.

diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html index ffb0cc1..40eb2ea 100644 --- a/docs/it/privacy/index.html +++ b/docs/it/privacy/index.html @@ -189,7 +189,7 @@

Contatti

diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html index 2bb204f..f0e1d09 100644 --- a/docs/it/threat-model/index.html +++ b/docs/it/threat-model/index.html @@ -223,7 +223,7 @@

Segnalare una vulnerabilità

diff --git a/docs/ja/about/index.html b/docs/ja/about/index.html index ef2d015..2cbe255 100644 --- a/docs/ja/about/index.html +++ b/docs/ja/about/index.html @@ -153,7 +153,7 @@

謝辞

diff --git a/docs/ja/docs/index.html b/docs/ja/docs/index.html index f438ecc..febcec1 100644 --- a/docs/ja/docs/index.html +++ b/docs/ja/docs/index.html @@ -76,7 +76,7 @@

ドキュメント

Flowlight の使い方

-

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

+

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

diff --git a/docs/ja/index.html b/docs/ja/index.html index 7e3575f..2f5958a 100644 --- a/docs/ja/index.html +++ b/docs/ja/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

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

-

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

+

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

@@ -571,7 +571,7 @@

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

diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html index 556295b..aee6959 100644 --- a/docs/ja/privacy/index.html +++ b/docs/ja/privacy/index.html @@ -187,7 +187,7 @@

連絡先

diff --git a/docs/ja/threat-model/index.html b/docs/ja/threat-model/index.html index cc34dcf..9d34d73 100644 --- a/docs/ja/threat-model/index.html +++ b/docs/ja/threat-model/index.html @@ -222,7 +222,7 @@

脆弱性を報告する

diff --git a/docs/ko/about/index.html b/docs/ko/about/index.html index b1086fb..a47efae 100644 --- a/docs/ko/about/index.html +++ b/docs/ko/about/index.html @@ -153,7 +153,7 @@

감사

diff --git a/docs/ko/docs/index.html b/docs/ko/docs/index.html index c9c147f..9dbfce0 100644 --- a/docs/ko/docs/index.html +++ b/docs/ko/docs/index.html @@ -76,7 +76,7 @@

문서

Flowlight 사용하기

-

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

+

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

diff --git a/docs/ko/index.html b/docs/ko/index.html index 0bd4cdb..b812881 100644 --- a/docs/ko/index.html +++ b/docs/ko/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

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

brew install --cask xinbetween/tap/flowlight

-

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

+

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

@@ -572,7 +572,7 @@

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

diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html index ff38ea5..35c2df0 100644 --- a/docs/ko/privacy/index.html +++ b/docs/ko/privacy/index.html @@ -190,7 +190,7 @@

문의

diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html index af9a0bc..4203bc4 100644 --- a/docs/ko/threat-model/index.html +++ b/docs/ko/threat-model/index.html @@ -221,7 +221,7 @@

취약점 신고하기

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 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 @@

Review checklist

diff --git a/docs/privacy/index.html b/docs/privacy/index.html index 058b3d0..347192a 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -196,7 +196,7 @@

Contact

diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html index f8535b8..e2883eb 100644 --- a/docs/pt-PT/about/index.html +++ b/docs/pt-PT/about/index.html @@ -152,7 +152,7 @@

Agradecimentos

diff --git a/docs/pt-PT/docs/index.html b/docs/pt-PT/docs/index.html index 930bf7e..f905f67 100644 --- a/docs/pt-PT/docs/index.html +++ b/docs/pt-PT/docs/index.html @@ -76,7 +76,7 @@

Documentação

Usar o Flowlight

-

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

+

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

diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html index 4713eb8..fe88b78 100644 --- a/docs/pt-PT/index.html +++ b/docs/pt-PT/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

Veja a atividade de rede das suas apps.

brew install --cask xinbetween/tap/flowlight

-

v0.15.4macOS 15+UniversalGPL-3.0Sem telemetria

+

v0.15.5macOS 15+UniversalGPL-3.0Sem telemetria

@@ -572,7 +572,7 @@

Saiba o que sai do seu Mac.

diff --git a/docs/pt-PT/privacy/index.html b/docs/pt-PT/privacy/index.html index 452c2b6..f963a4a 100644 --- a/docs/pt-PT/privacy/index.html +++ b/docs/pt-PT/privacy/index.html @@ -189,7 +189,7 @@

Contacto

diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html index 7e4f703..10619b7 100644 --- a/docs/pt-PT/threat-model/index.html +++ b/docs/pt-PT/threat-model/index.html @@ -222,7 +222,7 @@

Comunicar uma vulnerabilidade

diff --git a/docs/releases/index.html b/docs/releases/index.html index 322d3d4..fffd7fe 100644 --- a/docs/releases/index.html +++ b/docs/releases/index.html @@ -61,7 +61,19 @@

What's new

-

0.15.4

Latest
+

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.
  • +
  • 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 @@

    What's new

diff --git a/docs/threat-model/index.html b/docs/threat-model/index.html index 6bdcaa1..7a3fa26 100644 --- a/docs/threat-model/index.html +++ b/docs/threat-model/index.html @@ -231,7 +231,7 @@

Reporting a vulnerability

diff --git a/docs/zh-Hans/about/index.html b/docs/zh-Hans/about/index.html index bb48a57..59f54b1 100644 --- a/docs/zh-Hans/about/index.html +++ b/docs/zh-Hans/about/index.html @@ -152,7 +152,7 @@

致谢

diff --git a/docs/zh-Hans/docs/index.html b/docs/zh-Hans/docs/index.html index 3c746bd..6060f6c 100644 --- a/docs/zh-Hans/docs/index.html +++ b/docs/zh-Hans/docs/index.html @@ -76,7 +76,7 @@

文档

使用 Flowlight

-

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

+

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

diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html index 9e37190..70c226b 100644 --- a/docs/zh-Hans/index.html +++ b/docs/zh-Hans/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

看清应用的网络活动。

brew install --cask xinbetween/tap/flowlight

-

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

+

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

@@ -569,7 +569,7 @@

知道什么离开了你的 Mac。

diff --git a/docs/zh-Hans/privacy/index.html b/docs/zh-Hans/privacy/index.html index 0c5ebe6..ebacd1a 100644 --- a/docs/zh-Hans/privacy/index.html +++ b/docs/zh-Hans/privacy/index.html @@ -187,7 +187,7 @@

联系方式

diff --git a/docs/zh-Hans/threat-model/index.html b/docs/zh-Hans/threat-model/index.html index d6966e6..1fe3dd1 100644 --- a/docs/zh-Hans/threat-model/index.html +++ b/docs/zh-Hans/threat-model/index.html @@ -210,7 +210,7 @@

报告漏洞

diff --git a/docs/zh-Hant/about/index.html b/docs/zh-Hant/about/index.html index 34b3288..b320e7a 100644 --- a/docs/zh-Hant/about/index.html +++ b/docs/zh-Hant/about/index.html @@ -152,7 +152,7 @@

致謝

diff --git a/docs/zh-Hant/docs/index.html b/docs/zh-Hant/docs/index.html index cbb844b..164dbca 100644 --- a/docs/zh-Hant/docs/index.html +++ b/docs/zh-Hant/docs/index.html @@ -76,7 +76,7 @@

說明文件

使用 Flowlight

-

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

+

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

diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html index 19e48c3..fab1ff4 100644 --- a/docs/zh-Hant/index.html +++ b/docs/zh-Hant/index.html @@ -48,7 +48,7 @@ - + @@ -90,7 +90,7 @@

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

-

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

+

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

@@ -570,7 +570,7 @@

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

diff --git a/docs/zh-Hant/privacy/index.html b/docs/zh-Hant/privacy/index.html index a1d79b3..c3eb5a1 100644 --- a/docs/zh-Hant/privacy/index.html +++ b/docs/zh-Hant/privacy/index.html @@ -187,7 +187,7 @@

聯絡

diff --git a/docs/zh-Hant/threat-model/index.html b/docs/zh-Hant/threat-model/index.html index 75f5668..cd46992 100644 --- a/docs/zh-Hant/threat-model/index.html +++ b/docs/zh-Hant/threat-model/index.html @@ -211,7 +211,7 @@

回報漏洞

diff --git a/project.yml b/project.yml index f5e5135..3baf01b 100644 --- a/project.yml +++ b/project.yml @@ -11,8 +11,8 @@ settings: DEVELOPMENT_TEAM: "" CODE_SIGN_STYLE: Automatic ENABLE_HARDENED_RUNTIME: YES - MARKETING_VERSION: "0.15.4" - CURRENT_PROJECT_VERSION: "41" + MARKETING_VERSION: "0.15.5" + CURRENT_PROJECT_VERSION: "42" targets: Flowlight: type: application diff --git a/site/pages/releases.html b/site/pages/releases.html index 87a9532..bd2c758 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -13,7 +13,19 @@

What's new

-

0.15.4

Latest
+

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.
  • +
  • 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.
  • diff --git a/project.yml b/project.yml index 3baf01b..5ebf857 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: "42" + CURRENT_PROJECT_VERSION: "43" targets: Flowlight: type: application diff --git a/site/pages/releases.html b/site/pages/releases.html index bd2c758..855cbd3 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -18,6 +18,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.
  • 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"