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..0e233050 100644 --- a/docs/404.html +++ b/docs/404.html @@ -13,17 +13,22 @@ + + + + + - + @@ -80,7 +85,7 @@

That page isn't here

Try the home page,

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..ade50096 100644 --- a/docs/about/index.html +++ b/docs/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -138,7 +152,7 @@

Thanks

diff --git a/docs/de/about/index.html b/docs/de/about/index.html index 00423f97..75760268 100644 --- a/docs/de/about/index.html +++ b/docs/de/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -137,7 +151,7 @@

Dank

diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html index 4957f7cb..cc9e2ce8 100644 --- a/docs/de/docs/index.html +++ b/docs/de/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

Dokumentation

Flowlight benutzen

-

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.13.7, macOS 15 oder neuer.

+

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.13.8, macOS 15 oder neuer.

diff --git a/docs/de/index.html b/docs/de/index.html index a9f96672..2b65dacc 100644 --- a/docs/de/index.html +++ b/docs/de/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

Sieh, was deine Apps im Netz tun.
brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+UniversalGPL-3.0Keine Telemetrie

+

v0.13.8macOS 15+UniversalGPL-3.0Keine Telemetrie

@@ -525,66 +539,6 @@

Wisse, was deinen Mac verlässt.

-
diff --git a/docs/de/privacy/index.html b/docs/de/privacy/index.html index 2e39a522..8b517d68 100644 --- a/docs/de/privacy/index.html +++ b/docs/de/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -173,7 +187,7 @@

Kontakt

diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html index 44dd57a3..ce583894 100644 --- a/docs/de/threat-model/index.html +++ b/docs/de/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -213,7 +227,7 @@

Eine Schwachstelle melden

diff --git a/docs/docs/index.html b/docs/docs/index.html index 2e7affc2..ef2849d7 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

Documentation

Using Flowlight

-

Everything from the first launch to tuning the agent rules. Flowlight 0.13.7, macOS 15 or later.

+

Everything from the first launch to tuning the agent rules. Flowlight 0.13.8, macOS 15 or later.

diff --git a/docs/es/about/index.html b/docs/es/about/index.html index 8195547a..63139031 100644 --- a/docs/es/about/index.html +++ b/docs/es/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -137,7 +151,7 @@

Agradecimientos

diff --git a/docs/es/docs/index.html b/docs/es/docs/index.html index 2a337947..f48981b5 100644 --- a/docs/es/docs/index.html +++ b/docs/es/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

Documentación

Usar Flowlight

-

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.13.7, macOS 15 o posterior.

+

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.13.8, macOS 15 o posterior.

diff --git a/docs/es/index.html b/docs/es/index.html index 3259cb99..d393c11a 100644 --- a/docs/es/index.html +++ b/docs/es/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

Observa la actividad de red de tus apps.

brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+UniversalGPL-3.0Sin telemetría

+

v0.13.8macOS 15+UniversalGPL-3.0Sin telemetría

@@ -525,66 +539,6 @@

Ten claro qué sale de tu Mac.

-
diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html index cc9d9ea5..4bac13d9 100644 --- a/docs/es/privacy/index.html +++ b/docs/es/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -173,7 +187,7 @@

Contacto

diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html index 87ba3513..e7eb23b4 100644 --- a/docs/es/threat-model/index.html +++ b/docs/es/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -208,7 +222,7 @@

Informar de una vulnerabilidad

diff --git a/docs/fr/about/index.html b/docs/fr/about/index.html index bfa0e093..7c618fed 100644 --- a/docs/fr/about/index.html +++ b/docs/fr/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -137,7 +151,7 @@

Remerciements

diff --git a/docs/fr/docs/index.html b/docs/fr/docs/index.html index d5e0d0d0..9038ec9b 100644 --- a/docs/fr/docs/index.html +++ b/docs/fr/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

Documentation

Utiliser Flowlight

-

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.13.7, macOS 15 ou version ultérieure.

+

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.13.8, macOS 15 ou version ultérieure.

diff --git a/docs/fr/index.html b/docs/fr/index.html index 66427d67..5f638136 100644 --- a/docs/fr/index.html +++ b/docs/fr/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

Voyez l’activité réseau de vos apps.

brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+UniverselGPL-3.0Sans télémétrie

+

v0.13.8macOS 15+UniverselGPL-3.0Sans télémétrie

@@ -525,66 +539,6 @@

Sachez ce qui quitte votre Mac.

-
diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html index 36d13ed0..880e71b9 100644 --- a/docs/fr/privacy/index.html +++ b/docs/fr/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -173,7 +187,7 @@

Contact

diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html index b447a865..7edc025f 100644 --- a/docs/fr/threat-model/index.html +++ b/docs/fr/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -211,7 +225,7 @@

Signaler une vulnérabilité

diff --git a/docs/index.html b/docs/index.html index f701fdce..bfb0ce6c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

See your apps' network activity.
brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+UniversalGPL-3.0No telemetry

+

v0.13.8macOS 15+UniversalGPL-3.0No telemetry

@@ -533,66 +547,6 @@

Know what leaves your Mac.

-
diff --git a/docs/it/about/index.html b/docs/it/about/index.html index 6bf1170a..0b8d8ebf 100644 --- a/docs/it/about/index.html +++ b/docs/it/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -137,7 +151,7 @@

Ringraziamenti

diff --git a/docs/it/docs/index.html b/docs/it/docs/index.html index 74709706..70fa40ac 100644 --- a/docs/it/docs/index.html +++ b/docs/it/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

Documentazione

Usare Flowlight

-

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.13.7, macOS 15 o successivo.

+

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.13.8, macOS 15 o successivo.

diff --git a/docs/it/index.html b/docs/it/index.html index 892ca771..f586401d 100644 --- a/docs/it/index.html +++ b/docs/it/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

Vedi la rete delle tue app.
Ca Metti una stella su GitHub

brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+UniversaleGPL-3.0Nessuna telemetria

+

v0.13.8macOS 15+UniversaleGPL-3.0Nessuna telemetria

@@ -525,66 +539,6 @@

Sappi che cosa esce dal tuo Mac.

-
diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html index bf5e4df8..f9fda606 100644 --- a/docs/it/privacy/index.html +++ b/docs/it/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -173,7 +187,7 @@

Contatti

diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html index 184c0129..34fe6e9c 100644 --- a/docs/it/threat-model/index.html +++ b/docs/it/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -209,7 +223,7 @@

Segnalare una vulnerabilità

diff --git a/docs/ja/about/index.html b/docs/ja/about/index.html index a50f4119..43a22d26 100644 --- a/docs/ja/about/index.html +++ b/docs/ja/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -138,7 +152,7 @@

謝辞

diff --git a/docs/ja/docs/index.html b/docs/ja/docs/index.html index 50693943..20ee938c 100644 --- a/docs/ja/docs/index.html +++ b/docs/ja/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

ドキュメント

Flowlight の使い方

-

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.13.7、macOS 15 以降。

+

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.13.8、macOS 15 以降。

diff --git a/docs/ja/index.html b/docs/ja/index.html index 4cb93a38..37741384 100644 --- a/docs/ja/index.html +++ b/docs/ja/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

アプリの通信が見える。
brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+ユニバーサルGPL-3.0テレメトリなし

+

v0.13.8macOS 15+ユニバーサルGPL-3.0テレメトリなし

@@ -524,66 +538,6 @@

Mac から何が出ていくのかを知る。

-
diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html index a2b8612d..9d86a958 100644 --- a/docs/ja/privacy/index.html +++ b/docs/ja/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -171,7 +185,7 @@

連絡先

diff --git a/docs/ja/threat-model/index.html b/docs/ja/threat-model/index.html index dd64e164..d6e9b8ff 100644 --- a/docs/ja/threat-model/index.html +++ b/docs/ja/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -210,7 +224,7 @@

脆弱性を報告する

diff --git a/docs/ko/about/index.html b/docs/ko/about/index.html index 55443380..618450de 100644 --- a/docs/ko/about/index.html +++ b/docs/ko/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -138,7 +152,7 @@

감사

diff --git a/docs/ko/docs/index.html b/docs/ko/docs/index.html index d3cffe2b..64e9f0d3 100644 --- a/docs/ko/docs/index.html +++ b/docs/ko/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

문서

Flowlight 사용하기

-

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.13.7, macOS 15 이상.

+

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.13.8, macOS 15 이상.

diff --git a/docs/ko/index.html b/docs/ko/index.html index ec3fa804..c8533a55 100644 --- a/docs/ko/index.html +++ b/docs/ko/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

앱이 무엇을 하는지 봅니다.

brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+유니버설GPL-3.0텔레메트리 없음

+

v0.13.8macOS 15+유니버설GPL-3.0텔레메트리 없음

@@ -525,66 +539,6 @@

내 Mac에서 무엇이 나가는지 아세요.

-
diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html index 1e5e69ee..c938d219 100644 --- a/docs/ko/privacy/index.html +++ b/docs/ko/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -174,7 +188,7 @@

문의

diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html index 3b5a3564..e2116e5a 100644 --- a/docs/ko/threat-model/index.html +++ b/docs/ko/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -208,7 +222,7 @@

취약점 신고하기

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 6399d570..79d4b82f 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -62,7 +62,7 @@ Documentation Using Flowlight - Everything from the first launch to tuning the agent rules. Flowlight 0.13.7, macOS 15 or later. + Everything from the first launch to tuning the agent rules. Flowlight 0.13.8, macOS 15 or later. On this page @@ -439,6 +439,11 @@ open build/Build/Products/Release/Flowlight.app Rules are ordered and the first enabled match answers. Each switches on and off on its own. + Mock This… starts from the headers and bodies that Flowlight has already retained. Captured request details are + reference only: they do not add matching conditions and are not saved with the rule. Text and JSON can be edited directly; + binary bodies are shown as hexadecimal bytes. Saving keeps the response as a mock rule until you remove it, even if you later + clear captured traffic. + A mocked exchange is recorded like any other and marked Mocked in the list, with the detail pane saying it was answered by Flowlight rather than by the host, and naming the rule. You can always tell later what was real. @@ -740,10 +745,11 @@ open build/Build/Products/Release/Flowlight.app Updates - When a new version appears, Flowlight tells you with a notification and a badge in the sidebar rather than interrupting what you're - doing; clicking either opens the update window. A check you start yourself opens it straight away. + After you quit Flowlight cleanly and launch it again, a newly found version opens the update window. At other times, + Flowlight uses a notification and sidebar badge rather than interrupting what you're doing; clicking either opens the same + window. A check you start yourself opens it straight away. - Flowlight checks GitHub for a new release on launch and once a day, and Check for Updates… is in the app + Flowlight checks GitHub after a clean relaunch and once a day, and Check for Updates… is in the app and menu bar menus. Download fetches the disk image to Downloads and checks it against the release's published SHA-256. Nothing is installed until you choose: Quit & Install closes Flowlight, replaces it with the new version and reopens it, keeping your history and settings. Install Later keeps you working. If Flowlight was installed with the .pkg, macOS asks for an administrator @@ -847,7 +853,7 @@ Understand your AI agents. brew install --cask xinbetween/tap/flowlightCopy - v0.13.7macOS 15+UniversalGPL-3.0No telemetry + v0.13.8macOS 15+UniversalGPL-3.0No telemetry connectionslive @@ -1361,7 +1367,7 @@ Privacy RequestSent toWhat it containsWhen - Update checkapi.github.comA standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/ user agentOn launch and daily, from 0.1.2. Off switch in Settings › General + Update checkapi.github.comA standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/ user agentAfter a clean quit and relaunch, and daily. Off switch in Settings › General Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open @@ -1413,8 +1419,25 @@ Releases Downloads, checksums and full notes for each version are on GitHub Releases. + 0.13.8 +October 7, 2026Latest + + Faithful static mocks from inspected traffic. Mock This Endpoint now prepopulates + retained, redacted request and response headers as editable key/value rows. Response text and JSON are + ready to edit; binary payloads are shown as validated hexadecimal and replayed byte-for-byte. Captured + request details remain reference-only, so matching still depends only on host, path and method and a + matching mock never reaches the origin. + + Cleaner upgrade prompts. After a clean quit and relaunch, Flowlight checks for a + newer non-skipped version and opens its existing Software Update window when one is available. Automatic + updates remain optional, background failures stay quiet, and every download is still verified before use. + + More discoverable, lighter site. The website now publishes fuller social and structured + metadata, validates canonical and alternate-language pages more rigorously, and keeps its home-page + connection log static instead of running a continuous animation. + 0.13.7 -October 6, 2026Latest +October 6, 2026 Current-Xcode signed packaging. Flowlight now packages the manually Developer-ID-signed app directly from its successful archive. This avoids Xcode 26’s unsupported export path for macOS archives diff --git a/docs/llms.txt b/docs/llms.txt index c2d9dc8e..ec3705bf 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,6 +1,6 @@ # Flowlight -> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.13.7. +> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.13.8. - [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg) - [Source code](https://github.com/xinbetween/flowlight) diff --git a/docs/privacy/index.html b/docs/privacy/index.html index 9db96f0e..03069c2b 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -114,7 +128,7 @@

HTTPS inspection

Network requests the app makes

- + @@ -180,7 +194,7 @@

Contact

diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html index ab369dd6..b3f5f3b5 100644 --- a/docs/pt-PT/about/index.html +++ b/docs/pt-PT/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -137,7 +151,7 @@

Agradecimentos

diff --git a/docs/pt-PT/docs/index.html b/docs/pt-PT/docs/index.html index d2a307f2..35bc47bb 100644 --- a/docs/pt-PT/docs/index.html +++ b/docs/pt-PT/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

Documentação

Usar o Flowlight

-

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.13.7, macOS 15 ou posterior.

+

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.13.8, macOS 15 ou posterior.

diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html index 4eeb99bd..b7214728 100644 --- a/docs/pt-PT/index.html +++ b/docs/pt-PT/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

Veja a atividade de rede das suas apps.

brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+UniversalGPL-3.0Sem telemetria

+

v0.13.8macOS 15+UniversalGPL-3.0Sem telemetria

@@ -525,66 +539,6 @@

Saiba o que sai do seu Mac.

-
diff --git a/docs/pt-PT/privacy/index.html b/docs/pt-PT/privacy/index.html index 8475c275..7a0cd557 100644 --- a/docs/pt-PT/privacy/index.html +++ b/docs/pt-PT/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -173,7 +187,7 @@

Contacto

diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html index 354da6cd..85992fc1 100644 --- a/docs/pt-PT/threat-model/index.html +++ b/docs/pt-PT/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -210,7 +224,7 @@

Comunicar uma vulnerabilidade

diff --git a/docs/releases/index.html b/docs/releases/index.html index 2ea5f3bb..cdb5c8a7 100644 --- a/docs/releases/index.html +++ b/docs/releases/index.html @@ -13,17 +13,22 @@ + + + + + - + @@ -55,7 +60,24 @@

What's new

-

0.13.7

Latest
+

0.13.8

Latest
+
    +
  • Faithful static mocks from inspected traffic. Mock This Endpoint now prepopulates + retained, redacted request and response headers as editable key/value rows. Response text and JSON are + ready to edit; binary payloads are shown as validated hexadecimal and replayed byte-for-byte. Captured + request details remain reference-only, so matching still depends only on host, path and method and a + matching mock never reaches the origin.
  • +
  • Cleaner upgrade prompts. After a clean quit and relaunch, Flowlight checks for a + newer non-skipped version and opens its existing Software Update window when one is available. Automatic + updates remain optional, background failures stay quiet, and every download is still verified before use.
  • +
  • More discoverable, lighter site. The website now publishes fuller social and structured + metadata, validates canonical and alternate-language pages more rigorously, and keeps its home-page + connection log static instead of running a continuous animation.
  • +
+
+ +
+

0.13.7

  • Current-Xcode signed packaging. Flowlight now packages the manually Developer-ID-signed app directly from its successful archive. This avoids Xcode 26’s unsupported export path for macOS archives @@ -1101,7 +1123,7 @@

    What's new

diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 6e2d61f1..35764afc 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -1,54 +1,54 @@ https://flowlight.xinbetween.com/about/2026-09-25 - https://flowlight.xinbetween.com/docs/2026-10-03 - https://flowlight.xinbetween.com/2026-10-01 - https://flowlight.xinbetween.com/privacy/2026-10-03 - https://flowlight.xinbetween.com/releases/2026-10-06 + https://flowlight.xinbetween.com/docs/2026-10-07 + https://flowlight.xinbetween.com/2026-10-07 + https://flowlight.xinbetween.com/privacy/2026-10-07 + https://flowlight.xinbetween.com/releases/2026-10-07 https://flowlight.xinbetween.com/threat-model/2026-09-27 https://flowlight.xinbetween.com/de/about/2026-09-26 https://flowlight.xinbetween.com/de/docs/2026-09-28 - https://flowlight.xinbetween.com/de/2026-10-01 + https://flowlight.xinbetween.com/de/2026-10-07 https://flowlight.xinbetween.com/de/privacy/2026-09-27 https://flowlight.xinbetween.com/de/threat-model/2026-09-27 https://flowlight.xinbetween.com/es/about/2026-09-26 https://flowlight.xinbetween.com/es/docs/2026-09-28 - https://flowlight.xinbetween.com/es/2026-10-01 + https://flowlight.xinbetween.com/es/2026-10-07 https://flowlight.xinbetween.com/es/privacy/2026-09-27 https://flowlight.xinbetween.com/es/threat-model/2026-09-27 https://flowlight.xinbetween.com/fr/about/2026-09-26 https://flowlight.xinbetween.com/fr/docs/2026-09-28 - https://flowlight.xinbetween.com/fr/2026-10-01 + https://flowlight.xinbetween.com/fr/2026-10-07 https://flowlight.xinbetween.com/fr/privacy/2026-09-27 https://flowlight.xinbetween.com/fr/threat-model/2026-09-27 https://flowlight.xinbetween.com/it/about/2026-09-26 https://flowlight.xinbetween.com/it/docs/2026-09-28 - https://flowlight.xinbetween.com/it/2026-10-01 + https://flowlight.xinbetween.com/it/2026-10-07 https://flowlight.xinbetween.com/it/privacy/2026-09-27 https://flowlight.xinbetween.com/it/threat-model/2026-09-27 https://flowlight.xinbetween.com/pt-PT/about/2026-09-26 https://flowlight.xinbetween.com/pt-PT/docs/2026-09-28 - https://flowlight.xinbetween.com/pt-PT/2026-10-01 + https://flowlight.xinbetween.com/pt-PT/2026-10-07 https://flowlight.xinbetween.com/pt-PT/privacy/2026-09-27 https://flowlight.xinbetween.com/pt-PT/threat-model/2026-09-27 https://flowlight.xinbetween.com/ja/about/2026-09-26 https://flowlight.xinbetween.com/ja/docs/2026-09-28 - https://flowlight.xinbetween.com/ja/2026-10-01 + https://flowlight.xinbetween.com/ja/2026-10-07 https://flowlight.xinbetween.com/ja/privacy/2026-09-27 https://flowlight.xinbetween.com/ja/threat-model/2026-09-27 https://flowlight.xinbetween.com/zh-Hans/about/2026-09-26 https://flowlight.xinbetween.com/zh-Hans/docs/2026-09-28 - https://flowlight.xinbetween.com/zh-Hans/2026-10-01 + https://flowlight.xinbetween.com/zh-Hans/2026-10-07 https://flowlight.xinbetween.com/zh-Hans/privacy/2026-09-27 https://flowlight.xinbetween.com/zh-Hans/threat-model/2026-09-27 https://flowlight.xinbetween.com/zh-Hant/about/2026-09-26 https://flowlight.xinbetween.com/zh-Hant/docs/2026-09-28 - https://flowlight.xinbetween.com/zh-Hant/2026-10-01 + https://flowlight.xinbetween.com/zh-Hant/2026-10-07 https://flowlight.xinbetween.com/zh-Hant/privacy/2026-09-27 https://flowlight.xinbetween.com/zh-Hant/threat-model/2026-09-27 https://flowlight.xinbetween.com/ko/about/2026-09-26 https://flowlight.xinbetween.com/ko/docs/2026-09-28 - https://flowlight.xinbetween.com/ko/2026-10-01 + https://flowlight.xinbetween.com/ko/2026-10-07 https://flowlight.xinbetween.com/ko/privacy/2026-09-27 https://flowlight.xinbetween.com/ko/threat-model/2026-09-27 diff --git a/docs/threat-model/index.html b/docs/threat-model/index.html index 51e06d81..cd76a943 100644 --- a/docs/threat-model/index.html +++ b/docs/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -209,7 +223,7 @@

Reporting a vulnerability

diff --git a/docs/zh-Hans/about/index.html b/docs/zh-Hans/about/index.html index 4d0510f6..13caf191 100644 --- a/docs/zh-Hans/about/index.html +++ b/docs/zh-Hans/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -137,7 +151,7 @@

致谢

diff --git a/docs/zh-Hans/docs/index.html b/docs/zh-Hans/docs/index.html index b0d85e12..376f29e6 100644 --- a/docs/zh-Hans/docs/index.html +++ b/docs/zh-Hans/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

文档

使用 Flowlight

-

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.13.7,macOS 15 或更高版本。

+

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.13.8,macOS 15 或更高版本。

diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html index 6d33063e..28bf9028 100644 --- a/docs/zh-Hans/index.html +++ b/docs/zh-Hans/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

看清应用的网络活动。

brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+通用架构GPL-3.0无遥测

+

v0.13.8macOS 15+通用架构GPL-3.0无遥测

@@ -522,66 +536,6 @@

知道什么离开了你的 Mac。

-
diff --git a/docs/zh-Hans/privacy/index.html b/docs/zh-Hans/privacy/index.html index 1b89b6a3..9e0734ce 100644 --- a/docs/zh-Hans/privacy/index.html +++ b/docs/zh-Hans/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -171,7 +185,7 @@

联系方式

diff --git a/docs/zh-Hans/threat-model/index.html b/docs/zh-Hans/threat-model/index.html index 726cd668..6437e86b 100644 --- a/docs/zh-Hans/threat-model/index.html +++ b/docs/zh-Hans/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -197,7 +211,7 @@

报告漏洞

diff --git a/docs/zh-Hant/about/index.html b/docs/zh-Hant/about/index.html index 00ae3f8e..37694afc 100644 --- a/docs/zh-Hant/about/index.html +++ b/docs/zh-Hant/about/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -137,7 +151,7 @@

致謝

diff --git a/docs/zh-Hant/docs/index.html b/docs/zh-Hant/docs/index.html index 5ab21edf..c4dbabdb 100644 --- a/docs/zh-Hant/docs/index.html +++ b/docs/zh-Hant/docs/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -61,7 +75,7 @@

說明文件

使用 Flowlight

-

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.13.7,macOS 15 或以上版本。

+

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.13.8,macOS 15 或以上版本。

diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html index ea517749..e39e05fd 100644 --- a/docs/zh-Hant/index.html +++ b/docs/zh-Hant/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -75,7 +89,7 @@

看見每個 App 的網路活動。
brew install --cask xinbetween/tap/flowlight

-

v0.13.7macOS 15+通用架構GPL-3.0無遙測

+

v0.13.8macOS 15+通用架構GPL-3.0無遙測

@@ -523,66 +537,6 @@

知道有什麼離開了你的 Mac。

-
diff --git a/docs/zh-Hant/privacy/index.html b/docs/zh-Hant/privacy/index.html index fd5e538f..075a1a1c 100644 --- a/docs/zh-Hant/privacy/index.html +++ b/docs/zh-Hant/privacy/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -171,7 +185,7 @@

聯絡

diff --git a/docs/zh-Hant/threat-model/index.html b/docs/zh-Hant/threat-model/index.html index 2a81809e..4405d0fe 100644 --- a/docs/zh-Hant/threat-model/index.html +++ b/docs/zh-Hant/threat-model/index.html @@ -20,21 +20,35 @@ + + + + + + + + + + + + + + - + @@ -198,7 +212,7 @@

回報漏洞

diff --git a/project.yml b/project.yml index bff9edf0..69778fa9 100644 --- a/project.yml +++ b/project.yml @@ -11,8 +11,8 @@ settings: DEVELOPMENT_TEAM: "" CODE_SIGN_STYLE: Automatic ENABLE_HARDENED_RUNTIME: YES - MARKETING_VERSION: "0.13.7" - CURRENT_PROJECT_VERSION: "28" + MARKETING_VERSION: "0.13.8" + CURRENT_PROJECT_VERSION: "29" targets: Flowlight: type: application diff --git a/scripts/build_site.py b/scripts/build_site.py index cf4803a1..11bab4ac 100755 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -196,7 +196,12 @@ def structured_data(meta, body, lang="en", docs_url=f"{DOMAIN}/docs/#install"): publisher = {"@type": "Organization", "@id": f"{DOMAIN}/#publisher", "name": "xinbetween", "url": f"{DOMAIN}/", "logo": f"{DOMAIN}/assets/icon.png", "sameAs": [f"https://github.com/{REPO}"]} - graph = [site, publisher] + page = {"@type": "WebPage", "@id": f"{DOMAIN}{path}#webpage", "url": f"{DOMAIN}{path}", + "name": title, "description": desc, "inLanguage": lang, "isPartOf": {"@id": site["@id"]}} + if meta.get("source"): + page["dateModified"] = last_changed(meta["source"]) + + graph = [site, publisher, page] if path == localized("/", lang): graph.append({ @@ -241,12 +246,13 @@ def plain(markup): return html.unescape(re.sub(r"\s+", " ", text)).strip() def document(meta, body, root, css_href, inline_css=None, index=True, lang="en", locale="en", alt="", - docs_url=f"{DOMAIN}/docs/#install"): + alternate_locales=(), docs_url=f"{DOMAIN}/docs/#install"): title, desc, path = meta["title"], meta["description"], meta["path"] style = f"" if inline_css else f'' # A page that isn't a destination shouldn't be one in search results either. robots = "" if index else '\n' canonical = f'\n' if index else "" + locale_alternates = "".join(f'\n' for value in alternate_locales) return f""" @@ -257,14 +263,19 @@ def document(meta, body, root, css_href, inline_css=None, index=True, lang="en", {robots}{canonical}{alt} - +{locale_alternates} + + + + + @@ -362,9 +373,11 @@ def where(stem, lang): full = fill(header + body + footer, ctx) dest = OUT / path.strip("/") / "index.html" if path != "/" else OUT / "index.html" dest.parent.mkdir(parents=True, exist_ok=True) + locales = [table[l].get("lang.locale", l) for l in order if l != lang and (stem, l) in sources] dest.write_text(document(meta, full, root, f"{root}assets/site.css?v={css_version}", lang=table[lang].get("lang.html", lang), locale=table[lang].get("lang.locale", lang), alt=alt, + alternate_locales=locales, docs_url=f"{DOMAIN}{where('docs', lang) if 'docs' in english else '/docs/'}#install")) pages.append((meta, body, lang)) if path == "/": diff --git a/scripts/check_site.py b/scripts/check_site.py index 08f809e7..953aadb0 100644 --- a/scripts/check_site.py +++ b/scripts/check_site.py @@ -8,7 +8,7 @@ python3 scripts/check_site.py # after scripts/build_site.py """ -import re, sys, pathlib, collections +import re, sys, pathlib, collections, json ROOT = pathlib.Path(__file__).resolve().parent.parent OUT = ROOT / "docs" @@ -32,6 +32,11 @@ SIDEBAR = re.compile(r"enum SidebarItem[^{]*\{\s*case ([\w, ]+)") ROW = re.compile(r"

") H1 = re.compile(r"]") +TITLE = re.compile(r"([^<]+)", re.I) +META = re.compile(r'(.*?)', re.S | re.I) def version_key(text): @@ -201,6 +206,76 @@ def entries(text): if count != 1: found.append(f"{page.relative_to(ROOT)}: {count}

elements, expected 1") + # 13. SEO metadata is generated centrally. Keeping its structural contract here means a template change cannot + # quietly publish pages that look fine in a browser but have no usable search/social identity. + expected_social = {"og:type", "og:site_name", "og:locale", "og:title", "og:description", "og:url", "og:image", + "og:image:type", "og:image:width", "og:image:height", "og:image:alt", "twitter:card", + "twitter:title", "twitter:description", "twitter:image", "twitter:image:alt"} + indexed = [page for page in PAGES if page.name != "404.html"] + canonicals = {} + for page in PAGES: + text = page.read_text() + if not TITLE.search(text) or not TITLE.search(text).group(1).strip(): + found.append(f"{page.relative_to(ROOT)}: missing non-empty ") + metadata = dict(META.findall(text)) + if not metadata.get("description", "").strip(): + found.append(f"{page.relative_to(ROOT)}: missing non-empty meta description") + is_indexed = page in indexed + canonical = CANONICAL.findall(text) + if is_indexed: + expected = "https://flowlight.xinbetween.com/" + ("" if page == OUT / "index.html" else str(page.relative_to(OUT).parent) + "/") + if canonical != [expected]: + found.append(f"{page.relative_to(ROOT)}: canonical must be exactly {expected}") + canonicals[page] = expected + missing = sorted(expected_social - set(metadata)) + if missing: + found.append(f"{page.relative_to(ROOT)}: missing social metadata {', '.join(missing)}") + elif metadata["og:url"] != expected or metadata["og:title"] != TITLE.search(text).group(1) or metadata["og:description"] != metadata["description"]: + found.append(f"{page.relative_to(ROOT)}: Open Graph URL/title/description disagree with page metadata") + elif canonical: + found.append(f"{page.relative_to(ROOT)}: noindex page must not have a canonical URL") + + for raw in JSON_LD.findall(text): + try: + graph = json.loads(raw).get("@graph", []) + except json.JSONDecodeError as error: + found.append(f"{page.relative_to(ROOT)}: invalid JSON-LD: {error.msg}") + continue + kinds = {node.get("@type") for node in graph} + if is_indexed and "WebPage" not in kinds: + found.append(f"{page.relative_to(ROOT)}: JSON-LD has no WebPage node") + is_home = page.name == "index.html" and (page.parent == OUT or page.parent.name in LANGS) + if is_home and "SoftwareApplication" not in kinds: + found.append(f"{page.relative_to(ROOT)}: home JSON-LD has no SoftwareApplication node") + if not is_home and is_indexed and "BreadcrumbList" not in kinds: + found.append(f"{page.relative_to(ROOT)}: page JSON-LD has no BreadcrumbList node") + + if is_indexed: + alternates = dict(ALTERNATE.findall(text)) + if alternates and "x-default" not in alternates: + found.append(f"{page.relative_to(ROOT)}: hreflang cluster has no x-default") + for lang, href in alternates.items(): + if lang == "x-default": + continue + target = next((candidate for candidate, url in canonicals.items() if url == href), None) + if target is not None and lang not in dict(ALTERNATE.findall(target.read_text())): + found.append(f"{page.relative_to(ROOT)}: hreflang {lang} is not reciprocal") + + # 14. The sitemap names exactly the pages allowed to be indexed, and robots advertises that sitemap. + sitemap = (OUT / "sitemap.xml").read_text() + sitemap_urls = set(re.findall(r"<loc>([^<]+)</loc>", sitemap)) + if sitemap_urls != set(canonicals.values()): + found.append("docs/sitemap.xml: URLs do not exactly match canonical indexable pages") + robots = (OUT / "robots.txt").read_text() + if "Sitemap: https://flowlight.xinbetween.com/sitemap.xml" not in robots: + found.append("docs/robots.txt: missing or incorrect sitemap declaration") + + # 15. The home is intentionally static/lightweight. A loop here has previously caused real-machine jank. + for source in sorted((ROOT / "site/pages").glob("**/index.html")): + source_text = source.read_text() + if re.search(r"\b(setInterval|setTimeout|requestAnimationFrame)\s*\(", source_text): + found.append(f"{source.relative_to(ROOT)}: home page has recurring JavaScript animation") + return found diff --git a/site/pages/de/index.html b/site/pages/de/index.html index 49de61c1..2589a279 100644 --- a/site/pages/de/index.html +++ b/site/pages/de/index.html @@ -471,63 +471,3 @@ <h2>Wisse, was deinen Mac verlässt.</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config – kein Netzwerkaufruf" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "verweigert", - kind: "block", note: "⛔ Regel „keine Paketinstallationen nach 18:00“" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Backup Drive verbunden · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ Agent kontaktierte einen unbenannten Host" }, - { app: "rapportd", dest: "Continuity · Gerät in der Nähe", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 Schutzregel: create_issue verweigert, Modell erfuhr den Grund" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ Agent öffnete eine SSH-Sitzung" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ Fragen: auf diesem Mac beantwortet, nichts gesendet" }, - { app: "sharingd", dest: "AirDrop · Gerät in der Nähe", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ von Flowlight beantwortet, nie nach außen gesendet" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/docs.html b/site/pages/docs.html index 072078d8..a7d2a7c6 100644 --- a/site/pages/docs.html +++ b/site/pages/docs.html @@ -329,6 +329,10 @@ <h2 id="mocks">Mock responses</h2> <li><strong>Method</strong> matches one verb, or any.</li> <li>Rules are ordered and the first enabled match answers. Each switches on and off on its own.</li> </ul> + <p><strong>Mock This…</strong> starts from the headers and bodies that Flowlight has already retained. Captured request details are + reference only: they do not add matching conditions and are not saved with the rule. Text and JSON can be edited directly; + binary bodies are shown as hexadecimal bytes. Saving keeps the response as a mock rule until you remove it, even if you later + clear captured traffic.</p> <p>A mocked exchange is recorded like any other and marked <strong>Mocked</strong> in the list, with the detail pane saying it was answered by Flowlight rather than by the host, and naming the rule. You can always tell later what was real.</p> <p>Two things worth knowing. Only traffic Flowlight decrypts can be mocked, so the endpoint has to be going through HTTPS @@ -570,9 +574,10 @@ <h2 id="data">Your data</h2> <strong>Capture › Clear all data</strong> deletes it.</p> <h2 id="updates">Updates</h2> - <p>When a new version appears, Flowlight tells you with a notification and a badge in the sidebar rather than interrupting what you're - doing; clicking either opens the update window. A check you start yourself opens it straight away.</p> - <p>Flowlight checks GitHub for a new release on launch and once a day, and <strong>Check for Updates…</strong> is in the app + <p>After you quit Flowlight cleanly and launch it again, a newly found version opens the update window. At other times, + Flowlight uses a notification and sidebar badge rather than interrupting what you're doing; clicking either opens the same + window. A check you start yourself opens it straight away.</p> + <p>Flowlight checks GitHub after a clean relaunch and once a day, and <strong>Check for Updates…</strong> is in the app and menu bar menus. <strong>Download</strong> fetches the disk image to Downloads and checks it against the release's published SHA-256. Nothing is installed until you choose: <strong>Quit & Install</strong> closes Flowlight, replaces it with the new version and reopens it, keeping your history and settings. <strong>Install Later</strong> keeps you working. If Flowlight was installed with the <code>.pkg</code>, macOS asks for an administrator diff --git a/site/pages/es/index.html b/site/pages/es/index.html index da622d2f..d94b5d77 100644 --- a/site/pages/es/index.html +++ b/site/pages/es/index.html @@ -471,63 +471,3 @@ <h2>Ten claro qué sale de tu Mac.</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — sin llamada de red" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "rechazada", - kind: "block", note: "⛔ regla «nada de instalar paquetes después de las 18:00»" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Backup Drive conectado · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ el agente contactó con un host sin nombre" }, - { app: "rapportd", dest: "Continuidad · dispositivo cercano", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 salvaguarda: create_issue rechazada, se le explicó al modelo" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ el agente abrió una sesión SSH" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ Preguntar: respondido en este Mac, nada enviado" }, - { app: "sharingd", dest: "AirDrop · dispositivo cercano", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ respondido por Flowlight, nunca se envió al servidor" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/fr/index.html b/site/pages/fr/index.html index 05271966..08a51af2 100644 --- a/site/pages/fr/index.html +++ b/site/pages/fr/index.html @@ -471,63 +471,3 @@ <h2>Sachez ce qui quitte votre Mac.</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — aucun appel réseau" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "refusé", - kind: "block", note: "⛔ règle « aucune installation de paquet après 18:00 »" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Disque de sauvegarde connecté · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ l’agent a contacté un hôte sans nom" }, - { app: "rapportd", dest: "Continuité · appareil à proximité", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 garde-fou : create_issue refusé, motif transmis au modèle" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ l’agent a ouvert une session SSH" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ Demander : réponse donnée sur ce Mac, rien n’a été envoyé" }, - { app: "sharingd", dest: "AirDrop · appareil à proximité", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ réponse de Flowlight, jamais transmise en amont" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/index.html b/site/pages/index.html index a19bd606..894c7c22 100644 --- a/site/pages/index.html +++ b/site/pages/index.html @@ -480,63 +480,3 @@ <h2>Know what leaves your Mac.</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — no network call" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "refused", - kind: "block", note: "⛔ rule “no package installs after 18:00”" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Backup Drive connected · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ agent contacted an unnamed host" }, - { app: "rapportd", dest: "Continuity · nearby device", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 guardrail: create_issue refused, model told why" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ agent opened an SSH session" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ Ask: answered on this Mac, nothing sent" }, - { app: "sharingd", dest: "AirDrop · nearby device", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ answered by Flowlight, never sent upstream" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/it/index.html b/site/pages/it/index.html index 4ee06877..8c5c8701 100644 --- a/site/pages/it/index.html +++ b/site/pages/it/index.html @@ -471,63 +471,3 @@ <h2>Sappi che cosa esce dal tuo Mac.</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — nessuna chiamata di rete" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "rifiutata", - kind: "block", note: "⛔ regola «niente installazioni di pacchetti dopo le 18:00»" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Backup Drive collegato · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ l'agente ha contattato un host senza nome" }, - { app: "rapportd", dest: "Continuity · dispositivo vicino", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 protezione: create_issue rifiutato, al modello è stato spiegato perché" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ l'agente ha aperto una sessione SSH" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ Chiedi: risposta su questo Mac, nulla inviato" }, - { app: "sharingd", dest: "AirDrop · dispositivo vicino", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ risposta di Flowlight, mai inoltrata" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/ja/index.html b/site/pages/ja/index.html index c575159c..291252b4 100644 --- a/site/pages/ja/index.html +++ b/site/pages/ja/index.html @@ -470,63 +470,3 @@ <h2>Mac から何が出ていくのかを知る。</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — ネットワーク呼び出しなし" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "拒否", - kind: "block", note: "⛔ ルール「18:00 以降はパッケージをインストールしない」" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Backup Drive を接続 · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ エージェントが名前のないホストに接続" }, - { app: "rapportd", dest: "Continuity · 近くのデバイス", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 ガードレール:create_issue を拒否、理由をモデルに伝達" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ エージェントが SSH セッションを開始" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ 質問:この Mac で回答、送信なし" }, - { app: "sharingd", dest: "AirDrop · 近くのデバイス", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ Flowlight が応答、上流へは送信していません" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/ko/index.html b/site/pages/ko/index.html index 2d7076fe..061471f2 100644 --- a/site/pages/ko/index.html +++ b/site/pages/ko/index.html @@ -471,63 +471,3 @@ <h2>내 Mac에서 무엇이 나가는지 아세요.</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — 네트워크 호출 없음" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "거부됨", - kind: "block", note: "⛔ 규칙 “18:00 이후 패키지 설치 금지”" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Backup Drive 연결됨 · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ 에이전트가 이름 없는 호스트에 접속" }, - { app: "rapportd", dest: "Continuity · 근처 기기", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 가드레일: create_issue 거부, 모델에 이유 전달" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ 에이전트가 SSH 세션을 열었음" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ 질문: 이 Mac에서 답변, 나간 것 없음" }, - { app: "sharingd", dest: "AirDrop · 근처 기기", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ Flowlight가 응답, 서버로는 전송 안 됨" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/privacy.html b/site/pages/privacy.html index 2a570b1d..3dbedb11 100644 --- a/site/pages/privacy.html +++ b/site/pages/privacy.html @@ -61,7 +61,7 @@ <h2 id="inspection">HTTPS inspection</h2> <h2 id="requests">Network requests the app makes</h2> <table> <tr><th>Request</th><th>Sent to</th><th>What it contains</th><th>When</th></tr> - <tr><td>Update check</td><td><code>api.github.com</code></td><td>A standard HTTPS request for the latest release; GitHub sees your IP address and the <code>Flowlight/<version></code> user agent</td><td>On launch and daily, from 0.1.2. Off switch in Settings › General</td></tr> + <tr><td>Update check</td><td><code>api.github.com</code></td><td>A standard HTTPS request for the latest release; GitHub sees your IP address and the <code>Flowlight/<version></code> user agent</td><td>After a clean quit and relaunch, and daily. Off switch in Settings › General</td></tr> <tr><td>Update download</td><td><code>github.com</code></td><td>The disk image and its checksum file</td><td>Only when you click Download & Open</td></tr> <tr><td>Reverse DNS</td><td>Your configured DNS resolver</td><td>A lookup of an IP address's registered name, when no hostname was seen</td><td>As needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this</td></tr> <tr><td>Network owner lookup</td><td>Team Cymru, over DNS</td><td>Public IP addresses with no known hostname. Private and reserved addresses never leave your Mac</td><td>On by default. Off switch in Capture › Identify network owners</td></tr> diff --git a/site/pages/pt-PT/index.html b/site/pages/pt-PT/index.html index e010b622..fc4372b8 100644 --- a/site/pages/pt-PT/index.html +++ b/site/pages/pt-PT/index.html @@ -471,63 +471,3 @@ <h2>Saiba o que sai do seu Mac.</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — sem chamada de rede" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "recusada", - kind: "block", note: "⛔ regra «sem instalar pacotes depois das 18:00»" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Disco de cópias de segurança ligado · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ o agente contactou um anfitrião sem nome" }, - { app: "rapportd", dest: "Continuidade · dispositivo próximo", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 proteção: create_issue recusada, modelo informado do motivo" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ o agente abriu uma sessão SSH" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ Perguntar: respondido neste Mac, nada enviado" }, - { app: "sharingd", dest: "AirDrop · dispositivo próximo", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ respondido pelo Flowlight, nunca enviado para o destino" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/releases.html b/site/pages/releases.html index c6f985a6..87298bff 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -13,7 +13,24 @@ <h1>What's new</h1> <div class="wrap" style="padding-block:8px 96px"> <article class="prose" style="max-width:820px;gap:28px"> <section class="release"> - <header><h2>0.13.7</h2><time datetime="2026-10-06">October 6, 2026</time><span class="tag">Latest</span></header> + <header><h2>0.13.8</h2><time datetime="2026-10-07">October 7, 2026</time><span class="tag">Latest</span></header> + <ul> + <li><strong>Faithful static mocks from inspected traffic.</strong> Mock This Endpoint now prepopulates + retained, redacted request and response headers as editable key/value rows. Response text and JSON are + ready to edit; binary payloads are shown as validated hexadecimal and replayed byte-for-byte. Captured + request details remain reference-only, so matching still depends only on host, path and method and a + matching mock never reaches the origin.</li> + <li><strong>Cleaner upgrade prompts.</strong> After a clean quit and relaunch, Flowlight checks for a + newer non-skipped version and opens its existing Software Update window when one is available. Automatic + updates remain optional, background failures stay quiet, and every download is still verified before use.</li> + <li><strong>More discoverable, lighter site.</strong> The website now publishes fuller social and structured + metadata, validates canonical and alternate-language pages more rigorously, and keeps its home-page + connection log static instead of running a continuous animation.</li> + </ul> + </section> + + <section class="release"> + <header><h2>0.13.7</h2><time datetime="2026-10-06">October 6, 2026</time></header> <ul> <li><strong>Current-Xcode signed packaging.</strong> Flowlight now packages the manually Developer-ID-signed app directly from its successful archive. This avoids Xcode 26’s unsupported export path for macOS archives diff --git a/site/pages/zh-Hans/index.html b/site/pages/zh-Hans/index.html index 1542ca86..d761ca61 100644 --- a/site/pages/zh-Hans/index.html +++ b/site/pages/zh-Hans/index.html @@ -468,63 +468,3 @@ <h2>知道什么离开了你的 Mac。</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — 没有网络请求" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "已拒绝", - kind: "block", note: "⛔ 规则“18:00 之后不安装软件包”" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● 已连接 Backup Drive · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ 代理联系了一台无名主机" }, - { app: "rapportd", dest: "连续互通 · 附近的设备", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 护栏:已拒绝 create_issue,并告知模型原因" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ 代理打开了一个 SSH 会话" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ 提问:在这台 Mac 上作答,什么都没发出" }, - { app: "sharingd", dest: "AirDrop · 附近的设备", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ 由 Flowlight 应答,从未发往上游" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script> diff --git a/site/pages/zh-Hant/index.html b/site/pages/zh-Hant/index.html index 5410179b..6b373ea0 100644 --- a/site/pages/zh-Hant/index.html +++ b/site/pages/zh-Hant/index.html @@ -469,63 +469,3 @@ <h2>知道有什麼離開了你的 Mac。</h2> </section> </main> -<script> - // Stream new lines into the connection log. The full log is readable at rest. - // - // The feed is a tour of what Flowlight does now: traffic and alerts, but also a refusal, a guardrail taking a - // tool away before the model is offered it, a mocked answer, peer-to-peer Wi-Fi, a device arriving, and a tool - // call tied to the request it caused. - (function () { - var log = document.getElementById("log"); - if (!log || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - var feed = [ - { app: "Chrome", dest: "github.com", proto: "https:443", dir: "down", bytes: "↓ 640 KB" }, - { app: "codex", dest: "api.openai.com", proto: "https:443", dir: "up", bytes: "↑ 900 KB" }, - { kind: "tool", note: "↳ Read: ~/.ssh/config — 沒有網路連線" }, - { app: "claude", dest: "registry.npmjs.org", proto: "https:443", dir: "down", bytes: "↓ 300 KB" }, - { app: "claude › node", dest: "registry.npmjs.org", proto: "https:443", dir: "up", bytes: "已拒絕", - kind: "block", note: "⛔ 規則「18:00 之後不准安裝套件」" }, - { app: "zoom.us", dest: "zoom.us", proto: "zoom-media:8801", dir: "down", bytes: "↓ 6.1 MB" }, - { kind: "device", note: "● Backup Drive 已連接 · USB · 2 TB" }, - { app: "python3", dest: "203.0.113.45", proto: "tcp:4444", dir: "up", bytes: "↑ 40 KB", - kind: "flag", note: "⚠ 代理連線了沒有名稱的主機" }, - { app: "rapportd", dest: "Continuity · 附近的裝置", proto: "llw0", dir: "down", bytes: "↓ 310 KB", kind: "p2p" }, - { app: "claude", dest: "mcp.github.com", proto: "tools/call", dir: "down", bytes: "isError", - kind: "guard", note: "🛡 防護機制:create_issue 已拒絕,並告知模型原因" }, - { app: "Safari", dest: "en.wikipedia.org", proto: "https:443", dir: "down", bytes: "↓ 250 KB" }, - { app: "claude", dest: "github.com", proto: "ssh:22", dir: "up", bytes: "↑ 30 KB", - kind: "flag", note: "⚠ 代理開啟了 SSH 連線" }, - { app: "Flowlight", dest: "127.0.0.1", proto: "ollama:11434", dir: "up", bytes: "↑ 2 KB", - kind: "ask", note: "≡ 提問:在這台 Mac 上作答,沒有傳出任何資料" }, - { app: "sharingd", dest: "AirDrop · 附近的裝置", proto: "awdl0", dir: "up", bytes: "↑ 820 MB", kind: "p2p" }, - { app: "iCloud", dest: "p45-content.icloud.com", proto: "https:443", dir: "up", bytes: "↑ 700 KB" }, - { app: "Cursor", dest: "api2.cursor.sh", proto: "POST /v1/chat", dir: "down", bytes: "503", - kind: "mock", note: "✎ 由 Flowlight 回應,未送往上游" }, - ]; - var i = 0; - function line(r) { - var li = document.createElement("li"); - li.className = ["enter", r.kind || ""].join(" ").trim(); - // A row with no destination is an event rather than a connection: a tool call, a device arriving. - if (!r.dest) { - li.innerHTML = '<span class="app"></span><span class="note"></span>'; - li.querySelector(".note").textContent = r.note; - return li; - } - li.innerHTML = '<span class="app"></span><span class="dest"><b></b> · <i></i></span>' + - '<span class="bytes ' + (r.dir || "") + '"></span>' + (r.note ? '<span class="note"></span>' : ""); - li.querySelector(".app").textContent = r.app; - li.querySelector(".dest b").textContent = r.dest; - var proto = li.querySelector(".dest i"); - proto.textContent = r.proto; - proto.style.fontStyle = "normal"; - li.querySelector(".bytes").textContent = r.bytes; - if (r.note) li.querySelector(".note").textContent = r.note; - return li; - } - setInterval(function () { - log.insertBefore(line(feed[i++ % feed.length]), log.firstChild); - while (log.children.length > 12) log.removeChild(log.lastChild); - }, 1900); - })(); -</script>

RequestSent toWhat it containsWhen
Update checkapi.github.comA standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/<version> user agentOn launch and daily, from 0.1.2. Off switch in Settings › General
Update checkapi.github.comA standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/<version> user agentAfter a clean quit and relaunch, and daily. Off switch in Settings › General
Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open
Reverse DNSYour configured DNS resolverA lookup of an IP address's registered name, when no hostname was seenAs needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this
Network owner lookupTeam Cymru, over DNSPublic IP addresses with no known hostname. Private and reserved addresses never leave your MacOn by default. Off switch in Capture › Identify network owners
(?:]*>)?([^<]+)(?:)?\u2318(.)