Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 45 additions & 2 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ on:
push:
branches: [main]
paths: ["docs/**", ".github/workflows/pages.yml"]
workflow_dispatch:

permissions:
contents: read
deployments: read
pages: write
id-token: write

Expand All @@ -24,6 +26,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.
Expand All @@ -45,13 +49,52 @@ 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 }}
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
PREVIOUS_CATALOG=$(mktemp)
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
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-ref "$PREVIOUS_CATALOG_REF"
--previous "$PREVIOUS_CATALOG"
fi
APP_VERSION=$(sed -n 's/.*MARKETING_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml)
test -n "$APP_VERSION"
Expand Down
60 changes: 58 additions & 2 deletions Flowlight/Analysis/AgentWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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) {
Expand All @@ -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)
}

Expand All @@ -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<String>()
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)
Expand Down
2 changes: 2 additions & 0 deletions Flowlight/Analysis/RulePackCatalogState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
/// 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]
Expand Down
8 changes: 7 additions & 1 deletion Flowlight/Analysis/RulePackCatalogUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
51 changes: 39 additions & 12 deletions Flowlight/Analysis/RulePackStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,13 @@ 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?
private var definitionsValid = true
private var catalogReceipt: RulePackCatalogReceipt?
private var pendingReviewPackIDs = Set<String>()
private var committedSnapshot: RulePackSnapshot?
private var revision = 0
private var activityRevision = 0
Expand All @@ -93,6 +95,13 @@ final class RulePackStore: ObservableObject {

var enabledCount: Int { committedSnapshot?.configurations.count ?? 0 }
var hasEnabledPacks: Bool { committedSnapshot != nil }
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) }
}
var failureMessage: String? {
if case .failed(let message) = status { return message }
return nil
Expand All @@ -110,8 +119,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? {
Expand Down Expand Up @@ -140,11 +148,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)
}
Expand All @@ -167,7 +177,9 @@ final class RulePackStore: ObservableObject {
revision += 1
definitions = []
definitionsValid = true
isRecoveringCatalog = true
catalogReceipt = nil
pendingReviewPackIDs = []
configurations = []
overrides = []
committedSnapshot = nil
Expand All @@ -194,7 +206,9 @@ 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)
let known = Set(definitions.map(\.id))
overrides = acceptance.overrides.filter { known.contains($0.packID) }
Expand Down Expand Up @@ -245,7 +259,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,
Expand Down Expand Up @@ -274,6 +288,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
Expand All @@ -291,6 +306,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 {
Expand Down Expand Up @@ -399,15 +430,11 @@ 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) {
configuration.enabled = false
}
return configuration
var values = saved
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,
Expand Down
Loading
Loading