Skip to content
Closed
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
29 changes: 23 additions & 6 deletions Flowlight/Analysis/InstalledApps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ enum InstalledApps {
var bluetoothPurpose: String?
}

struct ScanResult: Sendable {
var apps: [App]
var complete: Bool
}

private static let lock = NSLock()
private static var cached: [App]?
private static var cached: ScanResult?

/// 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.
Expand All @@ -29,15 +34,22 @@ enum InstalledApps {

/// 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] {
static func all() -> [App] { scan().apps }

static func scan() -> ScanResult {
lock.lock()
if let cached { lock.unlock(); return cached }
lock.unlock()

var found: [String: App] = [:]
var complete = true
let manager = FileManager.default
for directory in searchPaths {
guard let entries = try? manager.contentsOfDirectory(atPath: directory) else { continue }
guard manager.fileExists(atPath: directory) else { continue }
guard let entries = try? manager.contentsOfDirectory(atPath: directory) else {
complete = false
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 }
Expand All @@ -49,9 +61,14 @@ enum InstalledApps {
found[id] = App(name: name, bundleID: id, path: 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
)
if result.complete {
lock.lock(); cached = result; lock.unlock()
}
return result
}

/// The applications built to use Bluetooth, as they say so themselves in their Info.plist.
Expand Down
33 changes: 26 additions & 7 deletions Flowlight/Analysis/RulePackStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 +76,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 +90,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 Down Expand Up @@ -171,19 +181,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)")
onFailure?()
}
}
}
Expand Down
60 changes: 60 additions & 0 deletions Flowlight/UI/RulePackEditorDraft.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation

/// The managed-pack editor is a draft until an explicit Save succeeds. Keeping these transitions outside SwiftUI
/// makes keyboard actions and sheet dismissal incapable of changing enforcement by accident.
struct RulePackEditorDraft {
enum Intent {
case review
case activate
}

enum AppSubmission: Equatable {
case loading
case ignored
case added(String)
}

enum SaveAction: Equatable {
case commit(RulePackConfiguration)
case confirmBroadening
}

let originalConfiguration: RulePackConfiguration
var configuration: RulePackConfiguration
var typedApp = ""

init(saved: RulePackConfiguration, intent: Intent) {
originalConfiguration = saved
configuration = saved
if intent == .activate { configuration.enabled = true }
}

var canSave: Bool {
configuration.scope != .selectedApps || !configuration.apps.isEmpty
}

mutating func submitApp(installed: [InstalledApps.App], appsLoaded: Bool = true) -> AppSubmission {
addApp(typedApp, installed: installed, appsLoaded: appsLoaded)
}

mutating func addApp(_ value: String, installed: [InstalledApps.App], appsLoaded: Bool = true) -> AppSubmission {
guard appsLoaded else { return .loading }
guard let target = InstalledApps.target(forTyped: value, apps: installed) else { return .ignored }
if !configuration.apps.contains(where: { $0.caseInsensitiveCompare(target.value) == .orderedSame }) {
configuration.apps.append(target.value)
configuration.apps.sort()
}
typedApp = ""
return .added(target.value)
}

func requestSave() -> SaveAction? {
guard canSave else { return nil }
if originalConfiguration.enabled,
originalConfiguration.scope == .selectedApps,
configuration.scope == .allApps {
return .confirmBroadening
}
return .commit(configuration)
}
}
Loading
Loading