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
17 changes: 14 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,18 +178,29 @@ jobs:
- name: Generate the project and reject drift
run: scripts/check_xcode_project.sh

- name: Check bounded notarization retries
- name: Check bounded notarization retries and warning gate
if: matrix.xcode == 'newest'
run: scripts/test-notarize.sh
run: |
scripts/test-notarize.sh
scripts/test-swift-warnings.sh

# This compiles a different product graph and activation path from the Developer ID build. The build clears
# protected entitlements because CI has no Store profiles; the next step re-signs the isolated product with the
# repository plists so the product gate still inspects the actual executable entitlements it is meant to police.
- name: Build the Mac App Store product
run: |
set +e
LOG="$RUNNER_TEMP/store-build.log"
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=
CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM= CODE_SIGN_ENTITLEMENTS= > "$LOG" 2>&1
status=$?
set -e
grep -E "warning:|error:|\\*\\* BUILD" "$LOG" | tail -80 || true
if [[ "$status" -ne 0 ]]; then
exit "$status"
fi
scripts/check-swift-warnings.sh "$LOG"

- name: Check the Mac App Store product structure and signed entitlements
run: |
Expand Down
18 changes: 12 additions & 6 deletions Flowlight.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2145,6 +2145,7 @@
PRODUCT_NAME = Flowlight;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) APP_STORE";
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
};
name = AppStore;
};
Expand Down Expand Up @@ -2183,7 +2184,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 55;
CURRENT_PROJECT_VERSION = 56;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2198,7 +2199,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.15.8;
MARKETING_VERSION = 0.15.9;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -2244,7 +2245,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 55;
CURRENT_PROJECT_VERSION = 56;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 38RJUJHKZS;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2259,7 +2260,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.15.8;
MARKETING_VERSION = 0.15.9;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
Expand Down Expand Up @@ -2324,7 +2325,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 55;
CURRENT_PROJECT_VERSION = 56;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -2345,7 +2346,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.15.8;
MARKETING_VERSION = 0.15.9;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
Expand Down Expand Up @@ -2410,6 +2411,7 @@
SDKROOT = macosx;
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) APP_STORE";
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
};
name = Release;
};
Expand Down Expand Up @@ -2465,6 +2467,7 @@
PRODUCT_NAME = Flowlight;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) APP_STORE";
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
};
name = Release;
};
Expand All @@ -2485,6 +2488,7 @@
SDKROOT = macosx;
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) APP_STORE";
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
};
name = Debug;
};
Expand Down Expand Up @@ -2571,6 +2575,7 @@
SDKROOT = macosx;
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) APP_STORE";
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
};
name = AppStore;
};
Expand All @@ -2590,6 +2595,7 @@
PRODUCT_NAME = Flowlight;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) APP_STORE";
SWIFT_TREAT_WARNINGS_AS_ERRORS = YES;
};
name = Debug;
};
Expand Down
3 changes: 2 additions & 1 deletion Flowlight/Analysis/RulePackEventDelivery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ final class RulePackEventDelivery: @unchecked Sendable {
item.attemptID = thisAttempt
item.lease = lease
pending[deliveryID] = item
sink(item.batch, lease.claim) { [weak self] committed in
let claim: Claim = { lease.claim() }
sink(item.batch, claim) { [weak self] committed in
self?.queue.async {
self?.finished(committed: committed, deliveryID: deliveryID, attemptID: thisAttempt)
}
Expand Down
2 changes: 1 addition & 1 deletion Flowlight/Analysis/RuleStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Foundation
final class RuleStore: ObservableObject {
/// This run of Flowlight. A rule that lasts "until I quit" is measured against it, so nothing has to be swept
/// at quit — which a crash or a forced restart would skip anyway.
static let session = UUID().uuidString
nonisolated static let session = UUID().uuidString

@Published private(set) var rules: [Rule] = []
@Published private(set) var events: [RuleEventRecord] = []
Expand Down
2 changes: 1 addition & 1 deletion Flowlight/App/DemoInspection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ extension DemoData {
HTTPHeader(name: "Host", value: host), HTTPHeader(name: "Content-Type", value: "application/json"),
HTTPHeader(name: "Content-Length", value: "\(req.count)"),
] : requestHeaders
try db.insertExchange(HTTPExchange(
_ = try db.insertExchange(HTTPExchange(
id: nil, started: clock, duration: duration, scheme: "https", host: host, port: 443, method: method, path: path,
status: status, requestHeaders: HeaderRedaction.redact(headers), requestBody: req, requestSize: req.count, requestTruncated: false,
responseHeaders: [HTTPHeader(name: "Content-Type", value: contentType), HTTPHeader(name: "Date", value: "demo")],
Expand Down
12 changes: 6 additions & 6 deletions Flowlight/Ask/AskQueryRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ enum AskQueryRunner {
"apps": Int64(Set(matching.map(\.bundleID)).count)]),
summary: who.map { L("%@ in, %@ out for %@", received, sent, $0) }
?? L("%@ in, %@ out", received, sent),
filter: app.map { TrafficFilter(bundleID: bundleID(of: $0, in: rows)) } ?? .none,
filter: app.map { TrafficFilter(bundleID: bundleID(of: $0, in: rows)) } ?? TrafficFilter.none,
from: window.from, to: window.to,
chart: AskChart(kind: kind,
title: who.map { L("Received and sent for %@", $0) } ?? L("Received and sent"),
Expand All @@ -174,7 +174,7 @@ enum AskQueryRunner {
return Result(json: encode(payload),
summary: top.isEmpty ? L("nothing moved")
: top.map { "\($0.value.name) \(ByteFormat.string($0.value.counters.total))" }.joined(separator: ", "),
filter: .none, from: window.from, to: window.to,
filter: TrafficFilter.none, from: window.from, to: window.to,
chart: AskChart(kind: AskChart.kind(requested: chart, natural: points.isEmpty ? .none : .bar),
title: L("Busiest apps"), unit: .bytes,
points: AskChart.trimmed(points, kind: .bar)))
Expand All @@ -201,7 +201,7 @@ enum AskQueryRunner {
let points = top.map { AskChart.Point(label: $0.key, value: Double($0.value.counters.total)) }
return Result(json: encode(payload),
summary: top.isEmpty ? L("no destinations") : top.prefix(3).map(\.key).joined(separator: ", "),
filter: app.map { TrafficFilter(bundleID: bundleID(of: $0, in: rows)) } ?? .none,
filter: app.map { TrafficFilter(bundleID: bundleID(of: $0, in: rows)) } ?? TrafficFilter.none,
from: window.from, to: window.to,
chart: AskChart(kind: kind, title: L("Busiest destinations"), unit: .bytes,
points: AskChart.trimmed(points, kind: kind), primaryName: L("Total")))
Expand Down Expand Up @@ -229,7 +229,7 @@ enum AskQueryRunner {
return Result(json: encode(payload),
summary: top.isEmpty ? L("nothing new")
: top.prefix(3).map { "\($0.app) → \($0.destination)" }.joined(separator: ", "),
filter: app.map { TrafficFilter(bundleID: bundleID(of: $0, in: inWindow)) } ?? .none,
filter: app.map { TrafficFilter(bundleID: bundleID(of: $0, in: inWindow)) } ?? TrafficFilter.none,
from: window.from, to: window.to)
}

Expand All @@ -244,7 +244,7 @@ enum AskQueryRunner {
return Result(json: encode(payload),
summary: inWindow.isEmpty ? L("no alerts")
: L("%lld alerts, newest %@", inWindow.count, inWindow.first?.kind ?? ""),
filter: .none, from: window.from, to: window.to)
filter: TrafficFilter.none, from: window.from, to: window.to)
}

private static func agents(db: TrafficDatabase, window: (from: Date, to: Date), grain: Granularity) throws -> Result {
Expand Down Expand Up @@ -273,7 +273,7 @@ enum AskQueryRunner {
}
return Result(json: encode(payload),
summary: payload.isEmpty ? L("no agents") : payload.compactMap { $0["agent"] }.joined(separator: ", "),
filter: .none, from: window.from, to: window.to)
filter: TrafficFilter.none, from: window.from, to: window.to)
}

private static func overTime(db: TrafficDatabase, window: (from: Date, to: Date), app: String?,
Expand Down
8 changes: 4 additions & 4 deletions Flowlight/Inspection/InspectionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,13 @@ final class InspectionController: ObservableObject {
let (scope, _) = scopeAndList()
// A mocked exchange is always kept: an answer Flowlight invented has to be visible wherever it lands.
guard scope == .all || exchange.agent != nil || exchange.note != nil || exchange.mockRule != nil || exchange.responseTransform != nil else { return }
guard let self else { return }
Task { @MainActor in
Task { @MainActor [weak self] in
// Capture is committed first. The database transaction optionally creates a tiny derived candidate;
// the wake below schedules later work and never waits for model inference on the recorder path.
self.db?.async { [weak self] db in
guard let db = self?.db else { return }
db.async { [weak self] db in
_ = try db.insertExchange(exchange, enqueueLocalRisk: LocalRiskSettings.enabled)
Task { @MainActor in
Task { @MainActor [weak self] in
guard let self else { return }
self.recordedCount += 1
self.onRecorded()
Expand Down
2 changes: 1 addition & 1 deletion Flowlight/Localization/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ final class Localization: ObservableObject {
enum LanguageBundle {
nonisolated(unsafe) static var current: Bundle = .main
/// English, for the fallback below. Resolved once: a bundle lookup per string would be wasteful.
nonisolated(unsafe) static let english: Bundle? =
nonisolated static let english: Bundle? =
Bundle.main.path(forResource: "en", ofType: "lproj").flatMap(Bundle.init(path:))

static func string(_ key: String) -> String {
Expand Down
9 changes: 6 additions & 3 deletions Flowlight/Plugins/PluginPackageVerifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ enum PluginPackageVerifier {

private static func verify(_ signature: PluginSignature, digest: String, signer: PluginTrustedSigner) throws {
guard let signatureData = Data(base64Encoded: signature.value),
let publicKey = publicKey(fromPEM: signer.publicKeyPEM),
let publicKey = publicKey(from: signer.publicKeyPEM),
let parsed = try? P256.Signing.ECDSASignature(derRepresentation: signatureData) else {
throw PluginPackageError.invalidSignature
}
Expand All @@ -163,10 +163,13 @@ enum PluginPackageVerifier {
}
}

private static func publicKey(fromPEM pem: String) -> P256.Signing.PublicKey? {
private static func publicKey(from pem: String) -> P256.Signing.PublicKey? {
if pem.contains("-----BEGIN ") {
return try? P256.Signing.PublicKey(pemRepresentation: pem)
}
let lines = pem.split(whereSeparator: \.isNewline).map(String.init).filter { !$0.hasPrefix("-----") }
guard let data = Data(base64Encoded: lines.joined()) else { return nil }
return try? P256.Signing.PublicKey(pemRepresentation: pem) ?? (try? P256.Signing.PublicKey(derRepresentation: data))
return try? P256.Signing.PublicKey(derRepresentation: data)
}

private static func signatureMessage(digest: String, keyID: String) -> Data {
Expand Down
11 changes: 11 additions & 0 deletions FlowlightTests/AskTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@ final class AskTests: XCTestCase {
XCTAssertEqual(rows.first?["app"], "claude")
}

func testUnscopedTrafficAnswersKeepAnEmptyReportsFilter() throws {
try seed()
for query in [AskQuery.trafficTotals, .topApps, .topDestinations, .newDestinations, .alerts, .agents] {
let result = try AskQueryRunner.run(
AskCall(query: query, arguments: ["from": window.from, "to": window.to]), db: db
)
XCTAssertNotNil(result.filter, "\(query.rawValue) should remain openable in Reports")
XCTAssertTrue(result.filter?.isEmpty == true, "\(query.rawValue) should show every row in its window")
}
}

func testTopDestinationsNamesWhereItWent() throws {
try seed()
let result = try AskQueryRunner.run(AskCall(query: .topDestinations, arguments: ["from": window.from, "to": window.to]), db: db)
Expand Down
37 changes: 37 additions & 0 deletions FlowlightTests/PluginTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,43 @@ final class PluginTests: XCTestCase {
XCTAssertEqual(review.manifest.provenance.approvalFingerprint, review.approvalFingerprint)
}

func testSignedCommunityPackageAcceptsBase64DERPublicKey() throws {
var manifest = PluginManifest(id: "thirdparty.der-key", name: "DER key", version: "0.1.0", kind: .traffic,
source: .installed, publisher: .thirdParty, guardrailProvider: .custom,
description: "Signed plugin.", privacySummary: "Uses metadata only.",
privacy: PluginPrivacyDisclosure(categories: [.metadataOnly], summary: "Metadata only."))
manifest.script = "function evaluate(context) { return []; }"
let privateKey = P256.Signing.PrivateKey()
let signer = PluginTrustedSigner(
keyID: "test-der-v1", publisher: .thirdParty, publisherName: "Test DER",
channel: .community, publicKeyPEM: privateKey.publicKey.derRepresentation.base64EncodedString()
)
let package = try PluginPackageVerifier.signedPackage(
manifest: manifest, keyID: signer.keyID, privateKey: privateKey
)
let review = try PluginPackageVerifier.review(data: JSONEncoder().encode(package), trustedSigners: [signer])
XCTAssertEqual(review.signatureStatus, .verifiedCommunity)
}

func testSignedPackageRejectsMalformedTrustedKey() throws {
var manifest = PluginManifest(id: "thirdparty.bad-key", name: "Bad key", version: "0.1.0", kind: .traffic,
source: .installed, publisher: .thirdParty, guardrailProvider: .custom,
description: "Signed plugin.", privacySummary: "Uses metadata only.",
privacy: PluginPrivacyDisclosure(categories: [.metadataOnly], summary: "Metadata only."))
manifest.script = "function evaluate(context) { return []; }"
let privateKey = P256.Signing.PrivateKey()
let signer = PluginTrustedSigner(keyID: "test-bad-v1", publisher: .thirdParty, publisherName: "Bad Key",
channel: .community, publicKeyPEM: "not a key")
let package = try PluginPackageVerifier.signedPackage(
manifest: manifest, keyID: signer.keyID, privateKey: privateKey
)
XCTAssertThrowsError(
try PluginPackageVerifier.review(data: JSONEncoder().encode(package), trustedSigners: [signer])
) { error in
XCTAssertEqual(error as? PluginPackageError, .invalidSignature)
}
}

func testSignedPackageRejectsTamperedManifest() throws {
var manifest = PluginManifest(id: "thirdparty.tamper", name: "Tamper", version: "0.1.0", kind: .traffic,
source: .installed, publisher: .thirdParty, guardrailProvider: .custom,
Expand Down
16 changes: 16 additions & 0 deletions FlowlightTests/RulePackEventDeliveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ final class RulePackEventDeliveryTests: XCTestCase {
XCTAssertEqual(captured[0].events, captured[1].events)
}

func testClaimCanCrossAQueueBoundaryAndCommit() {
let committed = expectation(description: "cross-queue claim committed")
let delivery = RulePackEventDelivery(retryDelay: 0.01, attemptTimeout: 1) { _, claim, completion in
DispatchQueue.global().async {
XCTAssertTrue(claim())
completion(true)
}
}

delivery.enqueue(event()) { result in
XCTAssertTrue(result)
committed.fulfill()
}
wait(for: [committed], timeout: 2)
}

func testTwoFailedAttemptsFailOpen() {
let attempts = LockedValue(0)
let allowed = expectation(description: "refusal abandoned")
Expand Down
10 changes: 10 additions & 0 deletions FlowlightTests/RuleTemplateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,16 @@ final class RuleTemplateTests: XCTestCase {
.allow)
}

func testAnAppSessionBlockUsesTheCurrentRunAndExpiresInTheNext() {
guard let template = RuleTemplate.all.first(where: { $0.id == "investigate.app-everywhere-this-session" }),
let rule = template.rules(for: subject).first else {
return XCTFail("the app session template is gone")
}
XCTAssertEqual(rule.schedule.session, RuleStore.session)
XCTAssertEqual(RuleBook.decide(facts(host: "example.com"), rules: [rule], session: RuleStore.session).verdict, .block)
XCTAssertEqual(RuleBook.decide(facts(host: "example.com"), rules: [rule], session: "a-later-run").verdict, .allow)
}

/// The narrow investigation rule has to lose to nothing and bite nothing else: it is the one someone reaches
/// for precisely because they don't want to disturb the rest of the Mac.
func testBlockingOneAppFromOneDestinationLeavesEveryoneElseAlone() {
Expand Down
2 changes: 1 addition & 1 deletion docs/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ <h1>That page isn't here</h1><p class="lede">Try the <a href="/">home page</a>,
<div class="legal">
<span>© 2026 The Flowlight contributors. Flowlight is free software, released under the
<a href="https://github.com/xinbetween/flowlight/blob/main/LICENSE">GNU General Public License v3.0</a>.</span>
<span>Version 0.15.8 · Not affiliated with Apple or any AI provider named on this site.</span>
<span>Version 0.15.9 · Not affiliated with Apple or any AI provider named on this site.</span>
</div>
</div>
</footer>
Expand Down
Loading
Loading