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
1 change: 1 addition & 0 deletions Flowlight/App/FlowlightApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// with its own database, and it must never strip the enforcement the real running app owns.
guard !FlowlightApp.runningTests, !DemoData.isEnabled else { return }
SettingsEnforcer.shared.relaxAll()
UpdateLaunchPolicy.markCleanExit()
}
}

Expand Down
50 changes: 43 additions & 7 deletions Flowlight/App/UpdateChecker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,27 @@ enum VersionCompare {
}
}

/// The one-shot clean-exit marker that decides whether the next automatic launch check should promote a discovered
/// update. Kept separate from networking so the lifecycle decision is testable without contacting GitHub.
enum UpdateLaunchPolicy {
static let cleanRelaunchKey = "updates.pendingCleanRelaunchCheck"

static func markCleanExit(defaults: UserDefaults = .standard) {
defaults.set(true, forKey: cleanRelaunchKey)
}

/// Reads and clears the marker before any network work, so one clean exit produces at most one promoted check.
static func consumeCleanRelaunch(defaults: UserDefaults = .standard) -> Bool {
let pending = defaults.bool(forKey: cleanRelaunchKey)
defaults.removeObject(forKey: cleanRelaunchKey)
return pending
}

static func shouldCheckAtLaunch(cleanRelaunch: Bool, lastCheck: Date?, now: Date = Date()) -> Bool {
cleanRelaunch || lastCheck.map { now.timeIntervalSince($0) >= 12 * 3600 } ?? true
}
}

/// Checks GitHub Releases for a newer Flowlight and fetches the disk image on request.
/// It makes one anonymous HTTPS request per check (no cookies, no identifiers beyond what any
/// request carries: IP address and a `Flowlight/<version>` user agent), and it can be turned off.
Expand Down Expand Up @@ -99,6 +120,8 @@ final class UpdateChecker: ObservableObject {
let repository: String
private var timer: Timer?
private var downloadTask: Task<Void, Never>?
/// Fixture seam for release metadata only; downloads always use the private ephemeral session below.
private let releaseTransport: (@Sendable (URL) async throws -> (Data, URLResponse))?

private lazy var session: URLSession = {
let config = URLSessionConfiguration.ephemeral
Expand All @@ -108,9 +131,11 @@ final class UpdateChecker: ObservableObject {
return URLSession(configuration: config)
}()

init(bundle: Bundle = .main) {
init(bundle: Bundle = .main,
releaseTransport: (@Sendable (URL) async throws -> (Data, URLResponse))? = nil) {
currentVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0"
repository = bundle.object(forInfoDictionaryKey: "FLUpdateRepository") as? String ?? "xinbetween/flowlight"
self.releaseTransport = releaseTransport
UserDefaults.standard.register(defaults: [Keys.automatic: true])
}

Expand All @@ -133,12 +158,16 @@ final class UpdateChecker: ObservableObject {

func start() {
guard !DemoData.isEnabled else { return }
// Consume before checking the preference: a clean exit belongs to this launch, not a later launch after
// automatic updates happen to be turned back on.
let cleanRelaunch = UpdateLaunchPolicy.consumeCleanRelaunch()
schedule()
guard automatic else { return }
// Shortly after launch, unless we checked within the last 12 hours.
if let lastCheck, Date().timeIntervalSince(lastCheck) < 12 * 3600 { return }
// A clean quit followed by relaunch gets one fresh, promoted check. Ordinary launches stay quiet when a
// recent successful check exists; both wait for the primary UI and capture setup to settle first.
guard UpdateLaunchPolicy.shouldCheckAtLaunch(cleanRelaunch: cleanRelaunch, lastCheck: lastCheck) else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
Task { await self?.check(userInitiated: false) }
Task { await self?.check(userInitiated: false, promoteIfAvailable: cleanRelaunch) }
}
}

Expand All @@ -150,7 +179,7 @@ final class UpdateChecker: ObservableObject {
}
}

func check(userInitiated: Bool) async {
func check(userInitiated: Bool, promoteIfAvailable: Bool = false) async {
switch state {
case .downloading, .ready, .installing:
if userInitiated { showWindow = true }
Expand All @@ -160,7 +189,12 @@ final class UpdateChecker: ObservableObject {
state = .checking
do {
let url = URL(string: "https://api.github.com/repos/\(repository)/releases/latest")!
let (data, response) = try await session.data(from: url)
let (data, response): (Data, URLResponse)
if let releaseTransport {
(data, response) = try await releaseTransport(url)
} else {
(data, response) = try await session.data(from: url)
}
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
guard code == 200 else { throw code == 404 ? UpdateError.noRelease : UpdateError.badResponse(code) }
let release = try AppRelease.parse(data)
Expand All @@ -174,7 +208,9 @@ final class UpdateChecker: ObservableObject {
// A background check doesn't interrupt what you're doing: it notifies, and the sidebar shows a
// badge. The notification is posted once per version.
let defaults = UserDefaults.standard
if defaults.string(forKey: Keys.notified) != pending.version {
if promoteIfAvailable {
showWindow = true
} else if defaults.string(forKey: Keys.notified) != pending.version {
defaults.set(pending.version, forKey: Keys.notified)
Notifier.postUpdate(version: pending.version, summary: ReleaseNotes.headline(pending.notes))
}
Expand Down
108 changes: 102 additions & 6 deletions Flowlight/Inspection/MockRule.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
import Foundation

/// How a saved mock body is represented in preferences. Text is emitted as UTF-8; hex is a lossless editable
/// representation of arbitrary captured bytes.
enum MockBodyEncoding: String, Codable, Equatable, Sendable {
case text
case hex
}

/// Captured body data shown while authoring a mock. This is draft-only data: it is never part of rule matching and
/// request values are never written into the mock-rule preferences.
struct MockBodyDraft: Equatable, Sendable {
var text: String
var encoding: MockBodyEncoding
var byteCount: Int
var unavailableReason: String?

static func captured(_ data: Data?, truncated: Bool, side: String) -> MockBodyDraft {
if truncated {
return MockBodyDraft(text: "", encoding: .text, byteCount: 0,
unavailableReason: L("The captured %@ body was truncated, so Flowlight cannot show it as a complete value.", side))
}
guard let data else {
return MockBodyDraft(text: "", encoding: .text, byteCount: 0,
unavailableReason: L("The captured %@ body is unavailable.", side))
}
switch BodyContent.classify(data) {
case .binary:
return MockBodyDraft(text: MockRule.hex(data), encoding: .hex, byteCount: data.count, unavailableReason: nil)
case .empty:
return MockBodyDraft(text: "", encoding: .text, byteCount: 0, unavailableReason: nil)
default:
return MockBodyDraft(text: String(decoding: data, as: UTF8.self), encoding: .text,
byteCount: data.count, unavailableReason: nil)
}
}
}

/// A canned answer for one endpoint: "reply to `POST api.example.com/v1/items` with a 503, after eight seconds".
///
/// It exists to watch an agent cope with an API that fails, stalls or answers with something odd, without breaking
Expand All @@ -21,6 +57,8 @@ struct MockRule: Codable, Equatable, Identifiable, Sendable {
var status = 500
var headers: [HTTPHeader] = []
var body = ""
/// Old rules decode as text. Hex lets a mock replay a captured binary response without corrupting it as UTF-8.
var bodyEncoding: MockBodyEncoding = .text
/// Seconds to wait before answering, so "the API stalls" is testable. The recorded exchange shows the wait as
/// its duration.
var delay: Double = 0
Expand Down Expand Up @@ -49,11 +87,11 @@ struct MockRule: Codable, Equatable, Identifiable, Sendable {
}

init(id: UUID = UUID(), enabled: Bool = true, name: String = "", host: String = "", path: String = "*",
method: String = "", status: Int = 500, headers: [HTTPHeader] = [], body: String = "", delay: Double = 0,
blocked: Bool = false) {
method: String = "", status: Int = 500, headers: [HTTPHeader] = [], body: String = "",
bodyEncoding: MockBodyEncoding = .text, delay: Double = 0, blocked: Bool = false) {
self.id = id; self.enabled = enabled; self.name = name; self.host = host; self.path = path
self.method = method; self.status = status; self.headers = headers; self.body = body; self.delay = delay
self.blocked = blocked
self.method = method; self.status = status; self.headers = headers; self.body = body
self.bodyEncoding = bodyEncoding; self.delay = delay; self.blocked = blocked
}
}

Expand All @@ -71,11 +109,36 @@ extension MockRule {
status = try c.decodeIfPresent(Int.self, forKey: .status) ?? 500
headers = try c.decodeIfPresent([HTTPHeader].self, forKey: .headers) ?? []
body = try c.decodeIfPresent(String.self, forKey: .body) ?? ""
bodyEncoding = try c.decodeIfPresent(MockBodyEncoding.self, forKey: .bodyEncoding) ?? .text
delay = try c.decodeIfPresent(Double.self, forKey: .delay) ?? 0
blocked = try c.decodeIfPresent(Bool.self, forKey: .blocked) ?? false
}
}

/// A selected recorded exchange plus the safe, temporary context needed to author a static mock.
struct MockRuleDraft: Identifiable, Equatable, Sendable {
var id: UUID { rule.id }
var rule: MockRule
var requestHeaders: [HTTPHeader]
var requestBody: MockBodyDraft
var responseBodyNote: String?

init(exchange: HTTPExchange, requestBody: Data?, responseBody: Data?) {
var rule = MockRule(mocking: exchange)
rule.status = exchange.status ?? rule.status
rule.headers = exchange.responseHeaders
let capturedResponse = MockBodyDraft.captured(responseBody, truncated: exchange.responseTruncated, side: L("response"))
if capturedResponse.unavailableReason == nil {
rule.body = capturedResponse.text
rule.bodyEncoding = capturedResponse.encoding
}
self.rule = rule
requestHeaders = exchange.requestHeaders
self.requestBody = MockBodyDraft.captured(requestBody, truncated: exchange.requestTruncated, side: L("request"))
responseBodyNote = capturedResponse.unavailableReason
}
}

// MARK: Matching

/// Whether a request is answered by a rule, and which one. Plain functions over plain data, like `AgentPolicy`:
Expand Down Expand Up @@ -105,14 +168,42 @@ enum MockRules {
// MARK: The answer

extension MockRule {
/// The bytes Flowlight writes for the configured body. Hex ignores whitespace so a wrapped editor remains easy
/// to read, but every remaining character must form a complete hexadecimal byte.
static func bodyData(_ body: String, encoding: MockBodyEncoding) -> Data? {
switch encoding {
case .text:
return Data(body.utf8)
case .hex:
let digits = body.filter { !$0.isWhitespace }
guard digits.count.isMultiple(of: 2) else { return nil }
var data = Data()
var index = digits.startIndex
while index < digits.endIndex {
let next = digits.index(index, offsetBy: 2)
guard let byte = UInt8(digits[index..<next], radix: 16) else { return nil }
data.append(byte)
index = next
}
return data
}
}

static func hex(_ data: Data) -> String {
data.map { String(format: "%02X", $0) }.joined()
}

var bodyData: Data? { Self.bodyData(body, encoding: bodyEncoding) }
var hasValidBody: Bool { bodyData != nil }

/// The canned response as it goes on the wire.
///
/// Flowlight writes the framing headers itself: a mock whose `Content-Length` disagreed with its body would
/// hang the client rather than test it. The connection is closed afterwards because nothing was sent upstream,
/// so there's no real response behind this one to keep a reused connection in step with.
func responseBytes() -> Data {
let bodyAllowed = status != 204 && status != 304 && !(100..<200).contains(status)
let payload = bodyAllowed ? Data(body.utf8) : Data()
let payload = bodyAllowed ? (bodyData ?? Data()) : Data()
var text = "HTTP/1.1 \(status) \(Self.reason(status))\r\n"
let written = Set(["content-length", "connection", "transfer-encoding"])
for header in headers {
Expand All @@ -121,7 +212,12 @@ extension MockRule {
text += "\(name): \(Self.headerSafe(header.value))\r\n"
}
if bodyAllowed, !headers.contains(where: { $0.name.caseInsensitiveCompare("Content-Type") == .orderedSame }) {
text += "Content-Type: \(body.hasPrefix("{") || body.hasPrefix("[") ? "application/json" : "text/plain; charset=utf-8")\r\n"
let type: String
switch bodyEncoding {
case .hex: type = "application/octet-stream"
case .text: type = body.hasPrefix("{") || body.hasPrefix("[") ? "application/json" : "text/plain; charset=utf-8"
}
text += "Content-Type: \(type)\r\n"
}
if bodyAllowed { text += "Content-Length: \(payload.count)\r\n" }
// Named in the response as well as in Flowlight, so an app or a log elsewhere on the Mac can also tell that
Expand Down
22 changes: 17 additions & 5 deletions Flowlight/UI/InspectView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ private struct InspectContent: View {
@State private var assessments: [Int64: RiskAssessment] = [:]
@State private var window: AgentWindow = .day
@State private var showSetup = false
/// A rule prefilled from a recorded exchange, waiting in the editor.
@State private var mockDraft: MockRule?
/// A rule plus safe, temporary captured context waiting in the mock editor.
@State private var mockDraft: MockRuleDraft?
/// A response-transform rule is created only after its selected exchange's stored response has been read.
@State private var responseTransformDraft: ResponseTransformDraft?
/// Setup was opened to look at the mock rules, so it opens on them.
Expand Down Expand Up @@ -98,7 +98,7 @@ private struct InspectContent: View {
if !LocalRiskSettings.isActive { potentialHarmOnly = false }
}
.sheet(item: $mockDraft) { draft in
MockRuleEditor(rule: draft, isNew: true) { inspection.mockRules.append($0) }
MockRuleEditor(rule: draft.rule, isNew: true, draft: draft) { inspection.mockRules.append($0) }
}
.sheet(item: $responseTransformDraft) { draft in
ResponseTransformRuleEditor(rule: draft.rule, isNew: true, draft: draft) { inspection.responseTransformRules.append($0) }
Expand All @@ -120,6 +120,18 @@ private struct InspectContent: View {
!DemoData.isEnabled && exchange.note == nil && exchange.mockRule == nil && exchange.status != nil && !exchange.responseTruncated
}

private func prepareMock(from exchange: HTTPExchange) {
Task {
let bodies: (request: Data, response: Data)?
if let id = exchange.id {
bodies = try? await monitor.read { try $0.exchangeBodies(id: id) }
} else {
bodies = nil
}
mockDraft = MockRuleDraft(exchange: exchange, requestBody: bodies?.request, responseBody: bodies?.response)
}
}

private func prepareResponseTransform(from exchange: HTTPExchange) {
Task {
let body: Data?
Expand Down Expand Up @@ -294,7 +306,7 @@ private struct InspectContent: View {
}
.contextMenu(forSelectionType: HTTPExchange.ID.self) { ids in
if !DemoData.isEnabled, let id = ids.first, let e = exchanges.first(where: { $0.id == id }), e.note == nil {
Button(L("Mock This Endpoint…")) { mockDraft = MockRule(mocking: e) }
Button(L("Mock This Endpoint…")) { prepareMock(from: e) }
if canModifyResponse(e) {
Button(L("Modify This Response…")) { prepareResponseTransform(from: e) }
}
Expand All @@ -316,7 +328,7 @@ private struct InspectContent: View {
@ViewBuilder private var detail: some View {
if let selected = exchanges.first(where: { $0.id == selection }) {
ExchangeDetail(exchange: selected, assessment: selected.id.flatMap { assessments[$0] }, cause: selected.id.flatMap { links[$0] },
results: results, highlight: search, mockThis: DemoData.isEnabled ? nil : { mockDraft = MockRule(mocking: selected) },
results: results, highlight: search, mockThis: DemoData.isEnabled ? nil : { prepareMock(from: selected) },
modifyResponse: canModifyResponse(selected) ? { prepareResponseTransform(from: selected) } : nil)
.id(selected.id)
} else {
Expand Down
Loading
Loading