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
86 changes: 66 additions & 20 deletions Flowlight/Analysis/InstalledApps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,41 +17,87 @@ enum InstalledApps {
var bluetoothPurpose: String?
}

private static let lock = NSLock()
private static var cached: [App]?
struct ScanResult: Sendable {
var apps: [App]
var complete: Bool
}

private static let condition = NSCondition()
private static var cached: ScanResult?
private static var scanning = false

/// Where applications live. The system folder is included because Safari and Mail are perfectly reasonable
/// things to focus on, and they aren't in /Applications.
private static var searchPaths: [String] {
["/Applications", "/Applications/Utilities", "/System/Applications", "/System/Applications/Utilities",
NSHomeDirectory() + "/Applications"]
["/Applications", "/System/Applications", NSHomeDirectory() + "/Applications"]
}

/// A complete catalog is retained for synchronous lookups. Callers that can be first to ask load off the main
/// actor; a partial scan is returned to that caller but never poisons every later lookup.
static func all() -> [App] { scan().apps }

static func scan(refresh: Bool = false) -> ScanResult {
cachedScan(refresh: refresh) { scan(in: searchPaths) }
}

/// Scanned once and kept. A Mac doesn't gain applications while a popover is open, and re-reading several
/// hundred Info.plists on every keystroke would be felt.
static func all() -> [App] {
lock.lock()
if let cached { lock.unlock(); return cached }
lock.unlock()
static func cachedScan(refresh: Bool = false, _ load: () -> ScanResult) -> ScanResult {
condition.lock()
if !refresh, let cached { condition.unlock(); return cached }
if scanning {
repeat { condition.wait() } while scanning
if let cached { condition.unlock(); return cached }
}
scanning = true
condition.unlock()

let result = load()
condition.lock()
if result.complete { cached = result }
scanning = false
condition.broadcast()
condition.unlock()
return result
}

static func resetCacheForTesting() {
condition.lock()
cached = nil
condition.unlock()
}

static func scan(in searchPaths: [String], manager: FileManager = .default) -> ScanResult {
var found: [String: App] = [:]
let manager = FileManager.default
var complete = true
for directory in searchPaths {
guard let entries = try? manager.contentsOfDirectory(atPath: directory) else { continue }
for entry in entries where entry.hasSuffix(".app") {
let path = directory + "/" + entry
guard let bundle = Bundle(path: path), let id = bundle.bundleIdentifier, !id.isEmpty else { continue }
guard manager.fileExists(atPath: directory) else { continue }
guard let enumerator = manager.enumerator(
at: URL(fileURLWithPath: directory),
includingPropertiesForKeys: [.isDirectoryKey, .isPackageKey],
options: [.skipsHiddenFiles],
errorHandler: { _, _ in complete = false; return true }
) else {
complete = false
continue
}
for case let url as URL in enumerator {
guard url.pathExtension.caseInsensitiveCompare("app") == .orderedSame else { continue }
enumerator.skipDescendants()
guard let bundle = Bundle(url: url), let id = bundle.bundleIdentifier, !id.isEmpty else {
continue
}
let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String)
?? String(entry.dropLast(4))
?? url.deletingPathExtension().lastPathComponent
let purpose = (bundle.object(forInfoDictionaryKey: "NSBluetoothAlwaysUsageDescription") as? String)
?? (bundle.object(forInfoDictionaryKey: "NSBluetoothPeripheralUsageDescription") as? String)
found[id] = App(name: name, bundleID: id, path: path, bluetoothPurpose: purpose)
found[id] = App(name: name, bundleID: id, path: url.path, bluetoothPurpose: purpose)
}
}
let sorted = found.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
lock.lock(); cached = sorted; lock.unlock()
return sorted
let result = ScanResult(
apps: found.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending },
complete: complete && !found.isEmpty
)
return result
}

/// The applications built to use Bluetooth, as they say so themselves in their Info.plist.
Expand Down
73 changes: 55 additions & 18 deletions Flowlight/Analysis/RulePackStore.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import Foundation

extension RulePackOverride {
func hasSameScope(as other: RulePackOverride) -> Bool {
packID == other.packID
&& app.caseInsensitiveCompare(other.app) == .orderedSame
&& host.caseInsensitiveCompare(other.host) == .orderedSame
&& match == other.match
}
}

/// The app-owned half of managed rule packs: built-in definitions, user activation/scope, exceptions, and the
/// complete validated snapshot handed to the Network Extension.
@MainActor
Expand Down Expand Up @@ -31,6 +40,10 @@ final class RulePackStore: ObservableObject {

var enabledCount: Int { configurations.filter(\.enabled).count }
var hasEnabledPacks: Bool { enabledCount > 0 }
var failureMessage: String? {
if case .failed(let message) = status { return message }
return nil
}

func configuration(for packID: String) -> RulePackConfiguration {
configurations.first { $0.packID == packID } ?? RulePackConfiguration(packID: packID)
Expand Down Expand Up @@ -72,8 +85,11 @@ final class RulePackStore: ObservableObject {
}
}

func save(_ configuration: RulePackConfiguration) {
guard definitions.contains(where: { $0.id == configuration.packID }) else { return }
func save(_ configuration: RulePackConfiguration, completion: ((Bool) -> Void)? = nil) {
guard definitions.contains(where: { $0.id == configuration.packID }) else {
completion?(false)
return
}
var cleaned = configuration
if cleaned.enabled, let definition = definitions.first(where: { $0.id == cleaned.packID }) {
cleaned.consentedVersion = definition.manifest.version
Expand All @@ -83,15 +99,18 @@ final class RulePackStore: ObservableObject {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty })).sorted()
if cleaned.scope == .selectedApps && cleaned.apps.isEmpty { cleaned.enabled = false }
persist({ try $0.saveRulePackConfiguration(cleaned) }) { [weak self] in
guard let self else { return }
persist({ try $0.saveRulePackConfiguration(cleaned) }, then: { [weak self] in
guard let self else { completion?(false); return }
if let index = self.configurations.firstIndex(where: { $0.packID == cleaned.packID }) {
self.configurations[index] = cleaned
} else {
self.configurations.append(cleaned)
}
self.publish()
}
completion?(true)
}, onFailure: {
completion?(false)
})
}

func setEnabled(_ packID: String, _ enabled: Bool) {
Expand All @@ -100,25 +119,34 @@ final class RulePackStore: ObservableObject {
save(value)
}

func addOverride(_ override: RulePackOverride) {
func addOverride(_ override: RulePackOverride, completion: ((Bool) -> Void)? = nil) {
guard definitions.contains(where: { $0.id == override.packID }),
let host = HostCanonicalizer.host(override.host) else { return }
let host = HostCanonicalizer.host(override.host) else {
completion?(false)
return
}
var cleaned = override
cleaned.host = host
cleaned.app = override.app.trimmingCharacters(in: .whitespacesAndNewlines)
persist({ try $0.saveRulePackOverride(cleaned) }) { [weak self] in
guard let self else { return }
self.overrides.removeAll { $0.id == cleaned.id }
persist({ try $0.saveRulePackOverride(cleaned) }, then: { [weak self] in
guard let self else { completion?(false); return }
self.overrides.removeAll { $0.id == cleaned.id || $0.hasSameScope(as: cleaned) }
self.overrides.append(cleaned)
self.publish()
}
completion?(true)
}, onFailure: {
completion?(false)
})
}

func deleteOverride(_ override: RulePackOverride) {
persist({ try $0.deleteRulePackOverride(override.id) }) { [weak self] in
self?.overrides.removeAll { $0.id == override.id }
func deleteOverride(_ override: RulePackOverride, completion: ((Bool) -> Void)? = nil) {
persist({ try $0.deleteRulePackOverride(override) }, then: { [weak self] in
self?.overrides.removeAll { $0.hasSameScope(as: override) }
self?.publish()
}
completion?(true)
}, onFailure: {
completion?(false)
})
}

func updateStatus(_ newStatus: ApplyStatus) { status = newStatus }
Expand Down Expand Up @@ -171,19 +199,28 @@ final class RulePackStore: ObservableObject {
}

private func persist(_ write: @escaping (TrafficDatabase) throws -> Void,
then commit: @escaping @MainActor () -> Void) {
then commit: @escaping @MainActor () -> Void,
onFailure: (@MainActor () -> Void)? = nil) {
guard let db else {
status = .failed("Rule pack storage is unavailable.")
onFailure?()
return
}
db.queue.async { [weak self, weak db] in
guard let db else { return }
guard let db else {
Task { @MainActor in
self?.status = .failed("Rule pack storage is unavailable.")
onFailure?()
}
return
}
do {
try write(db)
Task { @MainActor in commit() }
} catch {
Task { @MainActor in
self?.status = .failed("Could not save the rule pack change: \(error.localizedDescription)")
self?.status = .failed("Could not save the rule pack change: \(String(describing: error))")
onFailure?()
}
}
}
Expand Down
43 changes: 32 additions & 11 deletions Flowlight/Storage/TrafficDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@ struct RulePackEventRecord: Identifiable, Sendable, Hashable {
/// flows_1s → agg_1m (complete minutes) → agg_1h / agg_1d (folded from immutable minute rows).
final class TrafficDatabase: @unchecked Sendable {
static let tables = ["flows_1s", "agg_1m", "agg_1h", "agg_1d"]
private static let rulePackEventKey = [
"bucket", "pack_id", "pack_version", "entry_id", "category", "match", "action",
"bundle_id", "app_name", "agent", "agent_name", "host", "matched_host", "port",
]
private static let rulePackEventKeySQL = rulePackEventKey.joined(separator: ", ")
let queue = DispatchQueue(label: "flowlight.db", qos: .userInitiated)
private let conn: SQLiteConnection
let url: URL
Expand Down Expand Up @@ -490,8 +495,7 @@ final class TrafficDatabase: @unchecked Sendable {
count INTEGER NOT NULL, first_at INTEGER NOT NULL, last_at INTEGER NOT NULL,
bundle_id TEXT NOT NULL, app_name TEXT NOT NULL, agent TEXT NOT NULL, agent_name TEXT NOT NULL,
host TEXT NOT NULL, matched_host TEXT NOT NULL, port INTEGER NOT NULL,
UNIQUE(bucket, pack_id, pack_version, entry_id, category, match, action, bundle_id, app_name,
agent, agent_name, host, matched_host, port));
UNIQUE(\(Self.rulePackEventKeySQL)));
CREATE TABLE IF NOT EXISTS rule_pack_event_watermarks (
delivery_session TEXT PRIMARY KEY, sequence INTEGER NOT NULL, received INTEGER NOT NULL,
dropped_hits INTEGER NOT NULL, dropped_keys INTEGER NOT NULL);
Expand All @@ -502,7 +506,16 @@ final class TrafficDatabase: @unchecked Sendable {
"SELECT watermark FROM rollup_state WHERE tier = 'schema.rulePackEventKey'",
map: { $0.int(0) }
).first ?? 0
if packEventKeyVersion < 2 {
let packEventIndexes = try conn.query("PRAGMA index_list(rule_pack_event_rollups)") {
(name: $0.text(1), unique: $0.int(2) == 1, partial: $0.int(4) == 1)
}
let hasCurrentPackEventKey = try packEventIndexes
.filter { $0.unique && !$0.partial }
.contains { index in
let quoted = index.name.replacingOccurrences(of: "'", with: "''")
return try conn.query("PRAGMA index_info('\(quoted)')") { $0.text(2) } == Self.rulePackEventKey
}
if packEventKeyVersion < 2 || !hasCurrentPackEventKey {
let oldPackEventColumns = Set(try conn.query("PRAGMA table_info(rule_pack_event_rollups)") { $0.text(1) })
let oldMatch = oldPackEventColumns.contains("match") ? "match" : "'suffix'"
try conn.transaction {
Expand Down Expand Up @@ -1056,14 +1069,23 @@ final class TrafficDatabase: @unchecked Sendable {

func saveRulePackOverride(_ override: RulePackOverride) throws {
let json = String(decoding: try JSONEncoder().encode(override), as: UTF8.self)
try conn.run("""
INSERT INTO rule_pack_overrides (id, override, updated) VALUES (?,?,?)
ON CONFLICT(id) DO UPDATE SET override = excluded.override, updated = excluded.updated
""", [.text(override.id.uuidString), .text(json), .int(Int64(Date().timeIntervalSince1970))])
try conn.transaction {
for existing in try loadRulePackOverrides().filter({ $0.id != override.id && $0.hasSameScope(as: override) }) {
try conn.run("DELETE FROM rule_pack_overrides WHERE id = ?", [.text(existing.id.uuidString)])
}
try conn.run("""
INSERT INTO rule_pack_overrides (id, override, updated) VALUES (?,?,?)
ON CONFLICT(id) DO UPDATE SET override = excluded.override, updated = excluded.updated
""", [.text(override.id.uuidString), .text(json), .int(Int64(Date().timeIntervalSince1970))])
}
}

func deleteRulePackOverride(_ id: UUID) throws {
try conn.run("DELETE FROM rule_pack_overrides WHERE id = ?", [.text(id.uuidString)])
func deleteRulePackOverride(_ override: RulePackOverride) throws {
try conn.transaction {
for existing in try loadRulePackOverrides().filter({ $0.hasSameScope(as: override) }) {
try conn.run("DELETE FROM rule_pack_overrides WHERE id = ?", [.text(existing.id.uuidString)])
}
}
}

/// Commits a retryable extension delivery exactly once. One monotonic watermark per extension process and the
Expand Down Expand Up @@ -1103,8 +1125,7 @@ final class TrafficDatabase: @unchecked Sendable {
(bucket, pack_id, pack_version, entry_id, category, match, action, count, first_at, last_at,
bundle_id, app_name, agent, agent_name, host, matched_host, port)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(bucket, pack_id, pack_version, entry_id, category, match, action, bundle_id, app_name,
agent, agent_name, host, matched_host, port) DO UPDATE SET
ON CONFLICT(\(Self.rulePackEventKeySQL)) DO UPDATE SET
count = count + excluded.count,
first_at = MIN(first_at, excluded.first_at),
last_at = MAX(last_at, excluded.last_at)
Expand Down
6 changes: 4 additions & 2 deletions Flowlight/UI/RuleEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ struct RuleEditor: View {
@Environment(\.dismiss) private var dismiss
@State private var appQuery = ""
@State private var showAdvanced = false

private var apps: [InstalledApps.App] { InstalledApps.all() }
@State private var apps: [InstalledApps.App] = []

var body: some View {
VStack(alignment: .leading, spacing: 0) {
Expand Down Expand Up @@ -135,6 +134,9 @@ struct RuleEditor: View {
.padding(16)
.frame(width: 540)
.frame(minHeight: 520)
.task {
apps = await Task.detached(priority: .userInitiated) { InstalledApps.all() }.value
}
}

/// The rule, read back, above the fields that make it. An unfinished rule says so here rather than only at the
Expand Down
Loading
Loading