From 2c213251079a79382277bc94c6984e0d4426e1bd Mon Sep 17 00:00:00 2001 From: blessdyb Date: Wed, 7 Oct 2026 01:17:26 -0700 Subject: [PATCH 1/2] Improve mock authoring and update promotion Co-Authored-By: Claude Code --- Flowlight/App/FlowlightApp.swift | 1 + Flowlight/App/UpdateChecker.swift | 50 +++++- Flowlight/Inspection/MockRule.swift | 108 +++++++++++- Flowlight/UI/InspectView.swift | 22 ++- Flowlight/UI/MockRulesView.swift | 228 ++++++++++++++++++------- FlowlightTests/MockResponseTests.swift | 47 +++++ FlowlightTests/UpdateTests.swift | 117 +++++++++++++ README.md | 15 +- docs/404.html | 7 +- docs/DEVELOPMENT.md | 4 + docs/about/index.html | 16 +- docs/de/about/index.html | 16 +- docs/de/docs/index.html | 16 +- docs/de/index.html | 76 ++------- docs/de/privacy/index.html | 16 +- docs/de/threat-model/index.html | 16 +- docs/docs/index.html | 27 ++- docs/es/about/index.html | 16 +- docs/es/docs/index.html | 16 +- docs/es/index.html | 76 ++------- docs/es/privacy/index.html | 16 +- docs/es/threat-model/index.html | 16 +- docs/fr/about/index.html | 16 +- docs/fr/docs/index.html | 16 +- docs/fr/index.html | 76 ++------- docs/fr/privacy/index.html | 16 +- docs/fr/threat-model/index.html | 16 +- docs/index.html | 76 ++------- docs/it/about/index.html | 16 +- docs/it/docs/index.html | 16 +- docs/it/index.html | 76 ++------- docs/it/privacy/index.html | 16 +- docs/it/threat-model/index.html | 16 +- docs/ja/about/index.html | 16 +- docs/ja/docs/index.html | 16 +- docs/ja/index.html | 76 ++------- docs/ja/privacy/index.html | 16 +- docs/ja/threat-model/index.html | 16 +- docs/ko/about/index.html | 16 +- docs/ko/docs/index.html | 16 +- docs/ko/index.html | 76 ++------- docs/ko/privacy/index.html | 16 +- docs/ko/threat-model/index.html | 16 +- docs/llms-full.txt | 14 +- docs/privacy/index.html | 18 +- docs/pt-PT/about/index.html | 16 +- docs/pt-PT/docs/index.html | 16 +- docs/pt-PT/index.html | 76 ++------- docs/pt-PT/privacy/index.html | 16 +- docs/pt-PT/threat-model/index.html | 16 +- docs/releases/index.html | 7 +- docs/sitemap.xml | 24 +-- docs/threat-model/index.html | 16 +- docs/zh-Hans/about/index.html | 16 +- docs/zh-Hans/docs/index.html | 16 +- docs/zh-Hans/index.html | 76 ++------- docs/zh-Hans/privacy/index.html | 16 +- docs/zh-Hans/threat-model/index.html | 16 +- docs/zh-Hant/about/index.html | 16 +- docs/zh-Hant/docs/index.html | 16 +- docs/zh-Hant/index.html | 76 ++------- docs/zh-Hant/privacy/index.html | 16 +- docs/zh-Hant/threat-model/index.html | 16 +- scripts/build_site.py | 19 ++- scripts/check_site.py | 77 ++++++++- site/pages/de/index.html | 60 ------- site/pages/docs.html | 11 +- site/pages/es/index.html | 60 ------- site/pages/fr/index.html | 60 ------- site/pages/index.html | 60 ------- site/pages/it/index.html | 60 ------- site/pages/ja/index.html | 60 ------- site/pages/ko/index.html | 60 ------- site/pages/privacy.html | 2 +- site/pages/pt-PT/index.html | 60 ------- site/pages/zh-Hans/index.html | 60 ------- site/pages/zh-Hant/index.html | 60 ------- 77 files changed, 1402 insertions(+), 1364 deletions(-) diff --git a/Flowlight/App/FlowlightApp.swift b/Flowlight/App/FlowlightApp.swift index 3782434f..4d146b04 100644 --- a/Flowlight/App/FlowlightApp.swift +++ b/Flowlight/App/FlowlightApp.swift @@ -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() } } diff --git a/Flowlight/App/UpdateChecker.swift b/Flowlight/App/UpdateChecker.swift index 55983e15..fe3c15b9 100644 --- a/Flowlight/App/UpdateChecker.swift +++ b/Flowlight/App/UpdateChecker.swift @@ -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/` user agent), and it can be turned off. @@ -99,6 +120,8 @@ final class UpdateChecker: ObservableObject { let repository: String private var timer: Timer? private var downloadTask: Task? + /// 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 @@ -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]) } @@ -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) } } } @@ -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 } @@ -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) @@ -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)) } diff --git a/Flowlight/Inspection/MockRule.swift b/Flowlight/Inspection/MockRule.swift index ac38686c..b510e79c 100644 --- a/Flowlight/Inspection/MockRule.swift +++ b/Flowlight/Inspection/MockRule.swift @@ -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 @@ -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 @@ -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 } } @@ -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`: @@ -105,6 +168,34 @@ 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.. 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 @@ -112,7 +203,7 @@ extension MockRule { /// 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 { @@ -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 diff --git a/Flowlight/UI/InspectView.swift b/Flowlight/UI/InspectView.swift index 008d275e..dcd53e7b 100644 --- a/Flowlight/UI/InspectView.swift +++ b/Flowlight/UI/InspectView.swift @@ -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. @@ -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) } @@ -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? @@ -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) } } @@ -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 { diff --git a/Flowlight/UI/MockRulesView.swift b/Flowlight/UI/MockRulesView.swift index 8e6f25f2..9cb2d246 100644 --- a/Flowlight/UI/MockRulesView.swift +++ b/Flowlight/UI/MockRulesView.swift @@ -95,7 +95,8 @@ struct MockRulesSection: View { } } -/// One rule, edited in a sheet. Everything a canned answer needs and nothing else. +/// One rule, edited in a sheet. Captured request information is reference material only; the matcher remains host, +/// path and method, while the response is the durable mock setting. struct MockRuleEditor: View { private enum Tab: Hashable { case request, response } @@ -104,10 +105,24 @@ struct MockRuleEditor: View { var save: (MockRule) -> Void @Environment(\.dismiss) private var dismiss @State private var tab: Tab = .request - @State private var headerText = "" + @State private var requestHeaders: [MockHeaderRow] + @State private var responseHeaders: [MockHeaderRow] + @State private var requestBody: MockBodyDraft + private var responseBodyNote: String? @State private var statusText = "" @State private var delayText = "" + init(rule: MockRule, isNew: Bool, draft: MockRuleDraft? = nil, save: @escaping (MockRule) -> Void) { + _rule = State(initialValue: draft?.rule ?? rule) + self.isNew = isNew + self.save = save + _requestHeaders = State(initialValue: (draft?.requestHeaders ?? []).map(MockHeaderRow.init)) + _responseHeaders = State(initialValue: (draft?.rule.headers ?? rule.headers).map(MockHeaderRow.init)) + _requestBody = State(initialValue: draft?.requestBody ?? MockBodyDraft(text: "", encoding: .text, byteCount: 0, + unavailableReason: L("No captured request body."))) + responseBodyNote = draft?.responseBodyNote + } + var body: some View { VStack(alignment: .leading, spacing: 14) { Text(isNew ? L("New mock response") : L("Edit mock response")).font(.title3.bold()) @@ -126,11 +141,10 @@ struct MockRuleEditor: View { .pickerStyle(.segmented) .labelsHidden() - if tab == .request { - requestFields - } else { - responseFields + ScrollView { + if tab == .request { requestFields } else { responseFields } } + .frame(maxHeight: 410) if rule.delay > 0 { Label(L("The request waits %@ before it's answered. Clients with their own timeout will give up first, which is usually the point.", @@ -143,81 +157,84 @@ struct MockRuleEditor: View { Spacer() Button(isNew ? L("Add Rule") : L("Save")) { save(cleaned()); dismiss() } .keyboardShortcut(.defaultAction) - .disabled(rule.host.trimmingCharacters(in: .whitespaces).isEmpty) + .disabled(rule.host.trimmingCharacters(in: .whitespaces).isEmpty || !rule.hasValidBody) } } .padding(20) - .frame(width: 560) + .frame(width: 660) .onAppear { - headerText = MockRule.headerText(rule.headers) statusText = String(rule.status) delayText = rule.delay == 0 ? "" : String(rule.delay) } } private var requestFields: some View { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) { - GridRow { - Text(L("Host")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - TextField(L("api.example.com"), text: $rule.host) - Text(L("Exactly that host. Write *.example.com to cover the domain and its subdomains.")) - .font(.caption).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 14) { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) { + GridRow { + Text(L("Host")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + TextField(L("api.example.com"), text: $rule.host) + Text(L("Exactly that host. Write *.example.com to cover the domain and its subdomains.")) + .font(.caption).foregroundStyle(.secondary) + } } - } - GridRow { - Text(L("Path")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - TextField(L("/v1/*"), text: $rule.path) - Text(L("A glob: * matches any run of characters. The query string is ignored unless the pattern contains a ?.")) - .font(.caption).foregroundStyle(.secondary) + GridRow { + Text(L("Path")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + TextField(L("/v1/*"), text: $rule.path) + Text(L("A glob: * matches any run of characters. The query string is ignored unless the pattern contains a ?.")) + .font(.caption).foregroundStyle(.secondary) + } } - } - GridRow { - Text(L("Method")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) - Picker("", selection: Binding(get: { rule.method.isEmpty ? "ANY" : rule.method.uppercased() }, - set: { rule.method = $0 == "ANY" ? "" : $0 })) { - ForEach(MockRule.methods, id: \.self) { Text($0).tag($0) } + GridRow { + Text(L("Method")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) + Picker("", selection: Binding(get: { rule.method.isEmpty ? "ANY" : rule.method.uppercased() }, + set: { rule.method = $0 == "ANY" ? "" : $0 })) { + ForEach(MockRule.methods, id: \.self) { Text($0).tag($0) } + } + .labelsHidden().frame(width: 130) } - .labelsHidden().frame(width: 130) } + Divider() + Label(L("Captured request details are reference only. They do not change what this mock matches and are not saved with the rule."), + systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + MockHeaderRows(rows: $requestHeaders, emptyMessage: L("No captured request headers.")) + BodyEditor(title: L("Request payload"), text: $requestBody.text, encoding: $requestBody.encoding, + unavailableReason: requestBody.unavailableReason, editable: false) } } private var responseFields: some View { - Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) { - GridRow { - Text(L("Status")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) - HStack(spacing: 8) { - TextField("500", text: $statusText).frame(width: 70) - .onChange(of: statusText) { _, new in - if let code = Int(new.filter(\.isNumber)), (100...599).contains(code) { rule.status = code } - } - Text(MockRule.reason(rule.status)).font(.caption).foregroundStyle(.secondary) - Spacer() - Text(L("Delay")).foregroundStyle(.secondary) - TextField("0", text: $delayText).frame(width: 60) - .onChange(of: delayText) { _, new in rule.delay = min(300, max(0, Double(new) ?? 0)) } - Text(L("seconds")).font(.caption).foregroundStyle(.secondary) - } - } - GridRow(alignment: .top) { - Text(L("Headers")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - TextEditor(text: $headerText) - .font(.caption.monospaced()).frame(height: 54) - .border(.quaternary) - .onChange(of: headerText) { _, new in rule.headers = MockRule.parseHeaders(new) } - Text(L("One Name: value per line. Content-Length and Connection are written by Flowlight.")) - .font(.caption).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 14) { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 10, verticalSpacing: 8) { + GridRow { + Text(L("Status")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) + HStack(spacing: 8) { + TextField("500", text: $statusText).frame(width: 70) + .onChange(of: statusText) { _, new in + if let code = Int(new.filter(\.isNumber)), (100...599).contains(code) { rule.status = code } + } + Text(MockRule.reason(rule.status)).font(.caption).foregroundStyle(.secondary) + Spacer() + Text(L("Delay")).foregroundStyle(.secondary) + TextField("0", text: $delayText).frame(width: 60) + .onChange(of: delayText) { _, new in rule.delay = min(300, max(0, Double(new) ?? 0)) } + Text(L("seconds")).font(.caption).foregroundStyle(.secondary) + } } } - GridRow(alignment: .top) { - Text(L("Body")).gridColumnAlignment(.trailing).foregroundStyle(.secondary) - TextEditor(text: $rule.body) - .font(.caption.monospaced()).frame(height: 120) - .border(.quaternary) + MockHeaderRows(rows: $responseHeaders, emptyMessage: L("No response headers yet.")) + BodyEditor(title: L("Response body"), text: $rule.body, encoding: $rule.bodyEncoding, + unavailableReason: responseBodyNote, editable: true) + if !rule.hasValidBody { + Label(L("Binary mock bodies must contain complete hexadecimal byte pairs."), systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(FL.warning) } + Label(L("Saving keeps this response as a mock rule until you remove it, even if captured traffic is later cleared."), + systemImage: "archivebox") + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) } } @@ -229,11 +246,100 @@ struct MockRuleEditor: View { copy.path = copy.path.trimmingCharacters(in: .whitespaces) if copy.path.isEmpty { copy.path = "*" } copy.method = copy.method.trimmingCharacters(in: .whitespaces).uppercased() - copy.headers = MockRule.parseHeaders(headerText) + copy.headers = responseHeaders.compactMap { row in + let name = MockRule.headerSafe(row.name) + guard !name.isEmpty else { return nil } + return HTTPHeader(name: name, value: MockRule.headerSafe(row.value)) + } return copy } } +private struct MockHeaderRow: Identifiable, Equatable { + var id = UUID() + var name = "" + var value = "" + + init() {} + init(_ header: HTTPHeader) { name = header.name; value = header.value } +} + +private struct MockHeaderRows: View { + @Binding var rows: [MockHeaderRow] + var emptyMessage: String + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text(L("Headers")).font(.caption.bold()).foregroundStyle(.secondary) + Spacer() + Button { rows.append(MockHeaderRow()) } label: { Label(L("Add Header"), systemImage: "plus") } + .controlSize(.small) + } + if rows.isEmpty { + Text(emptyMessage).font(.caption).foregroundStyle(.tertiary) + } + ForEach($rows) { $row in + HStack(alignment: .top, spacing: 6) { + TextField(L("Name"), text: $row.name).frame(width: 150) + AdaptiveMockText(text: $row.value).frame(maxWidth: .infinity) + Button { rows.removeAll { $0.id == row.id } } label: { Image(systemName: "minus.circle.fill") } + .buttonStyle(.borderless).foregroundStyle(.secondary) + .accessibilityLabel(L("Remove header")) + } + } + Text(L("Content-Length, Connection and Transfer-Encoding are written by Flowlight.")) + .font(.caption).foregroundStyle(.secondary) + } + } +} + +private struct BodyEditor: View { + var title: String + @Binding var text: String + @Binding var encoding: MockBodyEncoding + var unavailableReason: String? + var editable: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack { + Text(title).font(.caption.bold()).foregroundStyle(.secondary) + if encoding == .hex { Label(L("Binary · hexadecimal"), systemImage: "doc.text") .font(.caption).foregroundStyle(.secondary) } + } + if let unavailableReason { + Label(unavailableReason, systemImage: "info.circle") + .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } else { + AdaptiveMockText(text: $text, forceMultiline: encoding == .hex) + .disabled(!editable) + if encoding == .hex { + Text(L("Each pair of hexadecimal characters is one byte. Flowlight writes the original bytes.")) + .font(.caption).foregroundStyle(.secondary) + } + } + } + } +} + +private struct AdaptiveMockText: View { + @Binding var text: String + var forceMultiline = false + private var multiline: Bool { forceMultiline || text.count > 80 || text.contains(where: \.isNewline) } + + var body: some View { + Group { + if multiline { + TextEditor(text: $text) + .font(.caption.monospaced()).frame(minHeight: forceMultiline ? 100 : 54, maxHeight: forceMultiline ? 180 : 100) + .border(.quaternary) + } else { + TextField("", text: $text).font(.caption.monospaced()) + } + } + } +} + enum DelayFormat { /// "8 s", "0.5 s" — short enough to sit inside a sentence. static func seconds(_ value: Double) -> String { diff --git a/FlowlightTests/MockResponseTests.swift b/FlowlightTests/MockResponseTests.swift index 80a2953a..38ec4e72 100644 --- a/FlowlightTests/MockResponseTests.swift +++ b/FlowlightTests/MockResponseTests.swift @@ -87,10 +87,45 @@ final class MockRuleMatchingTests: XCTestCase { XCTAssertTrue(rule.enabled) XCTAssertEqual(rule.path, "*") XCTAssertEqual(rule.status, 500) + XCTAssertEqual(rule.bodyEncoding, .text, "old rules remain UTF-8 text") XCTAssertEqual(rule.delay, 0) let roundTrip = try JSONDecoder().decode([MockRule].self, from: JSONEncoder().encode([rule])) XCTAssertEqual(roundTrip, [rule]) } + + func testDraftUsesStoredRedactedContextAndRepresentsBinaryAsHex() { + let exchange = HTTPExchange( + id: 1, started: Date(), duration: 0, scheme: "https", host: "api.example.com", port: 443, method: "POST", + path: "/v1/items?token=ignored", status: 201, + requestHeaders: [HTTPHeader(name: "Authorization", value: "Bearer ••• not kept (12 characters)"), + HTTPHeader(name: "Accept", value: "application/json")], requestBody: Data(), requestSize: 2, + requestTruncated: false, responseHeaders: [HTTPHeader(name: "Content-Type", value: "application/octet-stream")], + responseBody: Data(), responseSize: 3, responseTruncated: false, contentType: "", pid: 1, bundleID: "b", appName: "a", + agent: nil, agentName: nil, mcpServer: nil, toolCalls: [] + ) + let draft = MockRuleDraft(exchange: exchange, requestBody: Data(#"{"ok":true}"#.utf8), responseBody: Data([0, 0xFE, 0xFF])) + XCTAssertEqual(draft.rule.status, 201) + XCTAssertEqual(draft.rule.path, "/v1/items") + XCTAssertEqual(draft.requestHeaders, exchange.requestHeaders) + XCTAssertEqual(draft.requestBody.text, #"{"ok":true}"#) + XCTAssertEqual(draft.rule.bodyEncoding, .hex) + XCTAssertEqual(draft.rule.body, "00FEFF") + XCTAssertEqual(draft.rule.bodyData, Data([0, 0xFE, 0xFF])) + } + + func testTruncatedCapturedBodiesAreNotUsedAsMockResponses() { + var exchange = HTTPExchange( + id: 1, started: Date(), duration: 0, scheme: "https", host: "api.example.com", port: 443, method: "POST", + path: "/v1/items", status: 200, requestHeaders: [], requestBody: Data(), requestSize: 0, requestTruncated: true, + responseHeaders: [], responseBody: Data(), responseSize: 2, responseTruncated: true, contentType: "", pid: 1, + bundleID: "b", appName: "a", agent: nil, agentName: nil, mcpServer: nil, toolCalls: [] + ) + let draft = MockRuleDraft(exchange: exchange, requestBody: Data("partial".utf8), responseBody: Data("no".utf8)) + XCTAssertNotNil(draft.requestBody.unavailableReason) + XCTAssertNotNil(draft.responseBodyNote) + XCTAssertEqual(draft.rule.body, #"{"error": "mocked by Flowlight"}"#) + exchange.responseTruncated = false + } } final class MockResponseBytesTests: XCTestCase { @@ -132,6 +167,18 @@ final class MockResponseBytesTests: XCTestCase { XCTAssertEqual(headers, [HTTPHeader(name: "Retry-After", value: "30"), HTTPHeader(name: "X-Empty", value: "")]) XCTAssertEqual(MockRule.headerText(headers), "Retry-After: 30\nX-Empty: ") } + + func testHexBodyRoundTripsAndWritesBinaryLength() { + let rule = MockRule(host: "a.test", status: 200, body: "00 FE\nFF", bodyEncoding: .hex) + XCTAssertEqual(rule.bodyData, Data([0, 0xFE, 0xFF])) + let bytes = rule.responseBytes() + XCTAssertTrue(bytes.starts(with: Data("HTTP/1.1 200 OK\r\n".utf8))) + XCTAssertTrue(String(decoding: bytes, as: UTF8.self).contains("Content-Type: application/octet-stream\r\n")) + XCTAssertTrue(String(decoding: bytes, as: UTF8.self).contains("Content-Length: 3\r\n")) + XCTAssertEqual(bytes.suffix(3), Data([0, 0xFE, 0xFF])) + XCTAssertNil(MockRule.bodyData("0FG", encoding: .hex)) + XCTAssertNil(MockRule.bodyData("0", encoding: .hex)) + } } final class MockGateTests: XCTestCase { diff --git a/FlowlightTests/UpdateTests.swift b/FlowlightTests/UpdateTests.swift index befb4300..59f75d74 100644 --- a/FlowlightTests/UpdateTests.swift +++ b/FlowlightTests/UpdateTests.swift @@ -1,7 +1,58 @@ +import Foundation import XCTest @testable import Flowlight final class UpdateTests: XCTestCase { + private var skippedVersion: Any? + private var notifiedVersion: Any? + private var lastCheck: Any? + + override func setUp() { + super.setUp() + let defaults = UserDefaults.standard + skippedVersion = defaults.object(forKey: UpdateChecker.Keys.skipped) + notifiedVersion = defaults.object(forKey: UpdateChecker.Keys.notified) + lastCheck = defaults.object(forKey: UpdateChecker.Keys.lastCheck) + defaults.removeObject(forKey: UpdateChecker.Keys.skipped) + defaults.removeObject(forKey: UpdateChecker.Keys.notified) + defaults.removeObject(forKey: UpdateChecker.Keys.lastCheck) + } + + override func tearDown() { + let defaults = UserDefaults.standard + restore(skippedVersion, forKey: UpdateChecker.Keys.skipped, defaults: defaults) + restore(notifiedVersion, forKey: UpdateChecker.Keys.notified, defaults: defaults) + restore(lastCheck, forKey: UpdateChecker.Keys.lastCheck, defaults: defaults) + super.tearDown() + } + + private func restore(_ value: Any?, forKey key: String, defaults: UserDefaults) { + defaults.removeObject(forKey: key) + if let value { defaults.set(value, forKey: key) } + } + + @MainActor + private func fixtureChecker(version: String) -> (checker: UpdateChecker, version: String) { + let json = """ + {"tag_name":"v\(version)","name":"Flowlight \(version)","body":"Fixture release","html_url":"https://example.invalid/releases/v\(version)","draft":false,"prerelease":false,"assets":[]} + """ + let data = Data(json.utf8) + let response = HTTPURLResponse(url: URL(string: "https://api.github.com/repos/xinbetween/flowlight/releases/latest")!, + statusCode: 200, httpVersion: nil, headerFields: nil)! + return (UpdateChecker(releaseTransport: { _ in (data, response) }), version) + } + + @MainActor + private func currentVersion() -> String { + UpdateChecker().currentVersion + } + + @MainActor + private func nextMajorVersion() -> String { + let major = Int(currentVersion().split(separator: ".").first ?? "0") ?? 0 + return "\(major + 1).0.0" + } + func testVersionComparison() { XCTAssertTrue(VersionCompare.isNewer("0.1.2", than: "0.1.1")) XCTAssertTrue(VersionCompare.isNewer("0.10.0", than: "0.9.9"), "numeric, not lexical") @@ -22,6 +73,72 @@ final class UpdateTests: XCTestCase { XCTAssertEqual(VersionCompare.checksum(for: "Flowlight.dmg", in: "ABCD *Flowlight.dmg"), "abcd", "binary-mode marker") } + func testCleanRelaunchPolicyConsumesItsOneShotMarker() { + let suite = "update-policy-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + XCTAssertFalse(UpdateLaunchPolicy.consumeCleanRelaunch(defaults: defaults)) + UpdateLaunchPolicy.markCleanExit(defaults: defaults) + XCTAssertTrue(UpdateLaunchPolicy.consumeCleanRelaunch(defaults: defaults)) + XCTAssertFalse(UpdateLaunchPolicy.consumeCleanRelaunch(defaults: defaults), "a clean exit promotes only one next launch") + } + + func testCleanRelaunchBypassesTheNormalLaunchThrottle() { + let now = Date() + XCTAssertFalse(UpdateLaunchPolicy.shouldCheckAtLaunch(cleanRelaunch: false, lastCheck: now, now: now)) + XCTAssertTrue(UpdateLaunchPolicy.shouldCheckAtLaunch(cleanRelaunch: true, lastCheck: now, now: now)) + XCTAssertTrue(UpdateLaunchPolicy.shouldCheckAtLaunch(cleanRelaunch: false, lastCheck: now.addingTimeInterval(-12 * 3600), now: now)) + } + + @MainActor + func testCleanRelaunchPromotionOpensWindowForNewerRelease() async { + let release = await fixtureChecker(version: nextMajorVersion()) + + await release.checker.check(userInitiated: false, promoteIfAvailable: true) + + guard case .available(let available) = release.checker.state else { + return XCTFail("newer release should be available") + } + XCTAssertEqual(available.version, release.version) + XCTAssertTrue(release.checker.showWindow) + } + + @MainActor + func testCleanRelaunchPromotionSkipsSkippedRelease() async { + let release = await fixtureChecker(version: nextMajorVersion()) + UserDefaults.standard.set(release.version, forKey: UpdateChecker.Keys.skipped) + + await release.checker.check(userInitiated: false, promoteIfAvailable: true) + + guard case .available = release.checker.state else { + return XCTFail("skipped newer release should still be available") + } + XCTAssertNil(release.checker.pendingUpdate) + XCTAssertFalse(release.checker.showWindow) + } + + @MainActor + func testCleanRelaunchPromotionDoesNotOpenForCurrentRelease() async { + let checker = await fixtureChecker(version: currentVersion()) + + await checker.checker.check(userInitiated: false, promoteIfAvailable: true) + + XCTAssertEqual(checker.checker.state, .upToDate) + XCTAssertFalse(checker.checker.showWindow) + } + + @MainActor + func testCleanRelaunchPromotionDoesNotOpenWhenReleaseLookupFails() async { + let checker = UpdateChecker { _ in throw URLError(.notConnectedToInternet) } + + await checker.check(userInitiated: false, promoteIfAvailable: true) + + guard case .failed = checker.state else { + return XCTFail("a failed release lookup should report failure") + } + XCTAssertFalse(checker.showWindow) + } + func testReleaseNotesAreReadable() { let notes = "**Early preview.** Hi\n\n### What's new\n- One\n- Two\n\n### Checksums (SHA-256)\n```\nabc Flowlight.dmg\n```" let text = ReleaseNotes.readable(notes) diff --git a/README.md b/README.md index 8ab101e6..f23fa7e3 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,12 @@ on your Mac. For supported HTTP traffic routed through the proxy, it records hea ### Mock responses Answer a chosen host, path and method with your own status, headers, body and delay, to see how an agent copes when -an API fails or returns something odd. The request is never sent. Mocked exchanges are recorded and clearly marked, -so a session read later still shows what was real. Needs HTTPS inspection, since only decrypted traffic can be -answered — and a mock changes the reply, not whether the connection can be made. +an API fails or returns something odd. **Mock This…** pre-fills retained, redacted headers and payloads: request data +is reference-only, while the response can be edited as text/JSON or lossless hexadecimal for binary bytes. Saving +persists only the matcher and authored response rule; it remains until removed even if captured traffic is cleared. +The request is never sent. Mocked exchanges are recorded and clearly marked, so a session read later still shows what +was real. Needs HTTPS inspection, since only decrypted traffic can be answered — and a mock changes the reply, not +whether the connection can be made. ### Reports at any zoom - Second, minute, hour, day, week, month and year views. Click a bar to zoom in. @@ -308,8 +311,10 @@ same local database. Releases are signed and notarized, so it opens straight away. Prefer an installer? Every [release](https://github.com/xinbetween/flowlight/releases/latest) also has a `.pkg`. 2. Launch Flowlight. Traffic appears within a second, and the ↓↑ rates live in your menu bar. - Flowlight checks for new releases daily. It downloads and verifies an update, then asks before it quits to install - it and reopen (Flowlight › Check for Updates…). Updating by hand? Quit Flowlight before dragging the new version in. + After a clean quit and relaunch, Flowlight checks GitHub for a new release and opens its update window when one is + available; it also checks daily. The check is optional in Settings, and any download is verified before Flowlight + asks to quit and install it (Flowlight › Check for Updates…). Updating by hand? Quit Flowlight before dragging the + new version in. 3. On first launch, **Name Your Traffic** offers the one-time setup that lets Flowlight read hostnames (it asks for your password once). Network-owner lookups are on by default and can be turned off there or in Capture. diff --git a/docs/404.html b/docs/404.html index 3e1bef60..98633db3 100644 --- a/docs/404.html +++ b/docs/404.html @@ -13,17 +13,22 @@ + + + + + - + diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1d42e16e..2100844c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -181,6 +181,10 @@ agent can be shown an API that fails, stalls or replies with something odd. Rule server had sent it, so pairing, parsing and storage are unchanged. Flowlight writes `Content-Length`, `Connection: close` and `X-Flowlight-Mock` itself — a mock whose framing disagreed with its body would hang the client rather than test it — and strips CR/LF from rule-supplied headers so a rule can't forge a second response. +- **Draft versus rule data.** **Mock This…** reads only retained body blobs and already-redacted stored headers into a + temporary editor draft. Request headers and payload are reference-only and never become match criteria or durable + preferences. The saved response is deliberate rule data: text is encoded as UTF-8, binary bytes as validated hex, + and the rule remains in `inspection.mockRules` until removed even after captured traffic is cleared. - **A mocked exchange says so**: the rule's name goes in `HTTPExchange.mockRule` (column `mock_rule`, an in-place `ALTER`), not in `note`, which means "nothing was inspected". Inspect badges the row and leads the detail pane with the fact that the request was never sent, and the active rule count sits in the status bar — a mock left on diff --git a/docs/about/index.html b/docs/about/index.html index 7fda3e67..07f12912 100644 --- a/docs/about/index.html +++ b/docs/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + diff --git a/docs/de/about/index.html b/docs/de/about/index.html index 00423f97..885a7d9f 100644 --- a/docs/de/about/index.html +++ b/docs/de/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html index 4957f7cb..02b4f020 100644 --- a/docs/de/docs/index.html +++ b/docs/de/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + diff --git a/docs/de/index.html b/docs/de/index.html index a9f96672..bb448597 100644 --- a/docs/de/index.html +++ b/docs/de/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -525,66 +539,6 @@

Wisse, was deinen Mac verlässt.

-