git.iko.soy/parseable-macos
on master help sign in
Fix 11 bugs: NaN sort trap, numeric equality, filter drops rows, duplicate connections, credential loss, atomic keychain, and more
8aff8efiko
ParseableViewer/Models/JSONValue.swift+56−10
@@ -160,20 +160,22 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
160 /// Escapes special characters for valid JSON string output.
160 /// Escapes special characters for valid JSON string output.
161 static func escapeJSONString(_ s: String) -> String {
161 static func escapeJSONString(_ s: String) -> String {
162 var result = ""
162 var result = ""
163− result.reserveCapacity(s.count)
163+ result.unicodeScalars.reserveCapacity(s.unicodeScalars.count)
164− for char in s {
164+ for scalar in s.unicodeScalars {
165− switch char {
165+ switch scalar {
166 case "\"": result += "\\\""
166 case "\"": result += "\\\""
167 case "\\": result += "\\\\"
167 case "\\": result += "\\\\"
168 case "\n": result += "\\n"
168 case "\n": result += "\\n"
169 case "\r": result += "\\r"
169 case "\r": result += "\\r"
170 case "\t": result += "\\t"
170 case "\t": result += "\\t"
171 default:
171 default:
172− if char.asciiValue.map({ $0 < 0x20 }) == true {
172+ // Any control character (U+0000–U+001F), including those that
173− let code = char.asciiValue!
173+ // would otherwise combine into a grapheme cluster, must be
174+ // escaped to produce valid JSON.
175+ if scalar.value < 0x20 {
174− result += String(format: "\\u%04x", code)
176+ result += String(format: "\\u%04x", scalar.value)
175 } else {
177 } else {
176− result.append(char)
178+ result.unicodeScalars.append(scalar)
177 }
179 }
178 }
180 }
179 }
181 }
@@ -190,15 +192,59 @@ enum JSONValue: Codable, Hashable, Sendable, Comparable {
190 case (_, .null): return false
192 case (_, .null): return false
191 case (.bool(let a), .bool(let b)): return !a && b
193 case (.bool(let a), .bool(let b)): return !a && b
192 case (.int(let a), .int(let b)): return a < b
194 case (.int(let a), .int(let b)): return a < b
193− case (.double(let a), .double(let b)): return a < b
195+ case (.double(let a), .double(let b)): return doubleLessThan(a, b)
194− case (.int(let a), .double(let b)): return Double(a) < b
196+ case (.int(let a), .double(let b)): return doubleLessThan(Double(a), b)
195− case (.double(let a), .int(let b)): return a < Double(b)
197+ case (.double(let a), .int(let b)): return doubleLessThan(a, Double(b))
196 case (.string(let a), .string(let b)):
198 case (.string(let a), .string(let b)):
197 return a.localizedStandardCompare(b) == .orderedAscending
199 return a.localizedStandardCompare(b) == .orderedAscending
198 default:
200 default:
199 return lhs.displayString < rhs.displayString
201 return lhs.displayString < rhs.displayString
200 }
202 }
201 }
203 }
204+
205+ /// Total ordering for doubles that keeps `sort()` from trapping on NaN
206+ /// (IEEE comparisons with NaN are always false, which violates the strict
207+ /// weak ordering `Comparable` requires). NaN is ordered after all numbers.
208+ private static func doubleLessThan(_ a: Double, _ b: Double) -> Bool {
209+ if a.isNaN { return false }
210+ if b.isNaN { return true }
211+ return a < b
212+ }
213+
214+ // MARK: - Equatable / Hashable
215+
216+ /// Equality treats numerically-equal integers and doubles as equal
217+ /// (e.g. `.int(1) == .double(1.0)`) to stay consistent with `<`, which
218+ /// compares the two numerically. Synthesized conformance would instead
219+ /// distinguish them by enum case, violating the `Comparable` contract.
220+ static func == (lhs: JSONValue, rhs: JSONValue) -> Bool {
221+ switch (lhs, rhs) {
222+ case (.null, .null): return true
223+ case (.bool(let a), .bool(let b)): return a == b
224+ case (.int(let a), .int(let b)): return a == b
225+ case (.double(let a), .double(let b)): return a == b
226+ case (.int(let a), .double(let b)): return Double(a) == b
227+ case (.double(let a), .int(let b)): return a == Double(b)
228+ case (.string(let a), .string(let b)): return a == b
229+ case (.array(let a), .array(let b)): return a == b
230+ case (.object(let a), .object(let b)): return a == b
231+ default: return false
232+ }
233+ }
234+
235+ func hash(into hasher: inout Hasher) {
236+ switch self {
237+ case .null: hasher.combine(0)
238+ case .bool(let v): hasher.combine(v)
239+ // Hash integers via their Double value so `.int(1)` and `.double(1.0)`
240+ // — which are `==` — produce the same hash.
241+ case .int(let v): hasher.combine(Double(v))
242+ case .double(let v): hasher.combine(v)
243+ case .string(let v): hasher.combine(v)
244+ case .array(let v): hasher.combine(v)
245+ case .object(let v): hasher.combine(v)
246+ }
247+ }
202}
248}
203
249
204typealias LogRecord = [String: JSONValue]
250typealias LogRecord = [String: JSONValue]
ParseableViewer/Models/SQLTokenizer.swift+11−1
@@ -256,7 +256,17 @@ struct SQLTokenizer: Sendable {
256 }
256 }
257
257
258 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
258 private static func consumeNumber(_ i: inout String.Index, in sql: String) {
259+ // Digits with at most one decimal point, so malformed input like
260+ // `1.2.3` doesn't get swallowed into a single number token.
261+ var seenDot = false
259− advance(&i, in: sql, while: { $0.isNumber || $0 == "." })
262+ advance(&i, in: sql, while: { c in
263+ if c == "." {
264+ if seenDot { return false }
265+ seenDot = true
266+ return true
267+ }
268+ return c.isNumber
269+ })
260 // Exponent
270 // Exponent
261 if i < sql.endIndex, sql[i] == "e" || sql[i] == "E" {
271 if i < sql.endIndex, sql[i] == "e" || sql[i] == "E" {
262 sql.formIndex(after: &i)
272 sql.formIndex(after: &i)
ParseableViewer/Models/ServerConnection.swift+16
@@ -37,6 +37,22 @@ struct ServerConnection: Identifiable, Codable, Hashable, Sendable {
37 try container.encode(username, forKey: .username)
37 try container.encode(username, forKey: .username)
38 }
38 }
39
39
40+ // `password` is deliberately excluded from equality and hashing: it isn't
41+ // serialized (see CodingKeys) and is repopulated from the Keychain on
42+ // decode, so including it would make a decoded connection compare unequal
43+ // to the in-memory original whenever the Keychain read returns "" — e.g. in
44+ // Set membership or "has this connection changed?" checks.
45+ static func == (lhs: ServerConnection, rhs: ServerConnection) -> Bool {
46+ lhs.id == rhs.id
47+ && lhs.name == rhs.name
48+ && lhs.url == rhs.url
49+ && lhs.username == rhs.username
50+ }
51+
52+ func hash(into hasher: inout Hasher) {
53+ hasher.combine(id)
54+ }
55+
40 var baseURL: URL? {
56 var baseURL: URL? {
41 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
57 var urlString = url.trimmingCharacters(in: .whitespacesAndNewlines)
42 if urlString.isEmpty { return nil }
58 if urlString.isEmpty { return nil }
ParseableViewer/Services/ConnectionStore.swift+9−6
@@ -12,12 +12,15 @@ final class ConnectionStore {
12 }
12 }
13
13
14 static func saveConnections(_ connections: [ServerConnection]) {
14 static func saveConnections(_ connections: [ServerConnection]) {
15− for connection in connections {
15+ for connection in connections where !connection.password.isEmpty {
16− if !connection.password.isEmpty {
16+ // Only upsert non-empty passwords. We must NOT delete on an empty
17+ // password here: `ServerConnection.init(from:)` decodes the password
18+ // as "" whenever the Keychain read transiently fails (locked
19+ // Keychain, errSecInteractionNotAllowed, etc.), so deleting on empty
20+ // would promote a transient read failure into permanent credential
21+ // loss on the next save. Credentials are removed only when the
22+ // connection itself is removed, via `deleteConnection`.
17− KeychainService.savePassword(connection.password, for: connection.id)
23+ KeychainService.savePassword(connection.password, for: connection.id)
18− } else {
19− KeychainService.deletePassword(for: connection.id)
20− }
21 }
24 }
22 if let data = try? JSONEncoder().encode(connections) {
25 if let data = try? JSONEncoder().encode(connections) {
23 KeychainService.saveData(data, for: keychainConnectionsKey)
26 KeychainService.saveData(data, for: keychainConnectionsKey)
ParseableViewer/Services/KeychainService.swift+28−18
@@ -8,17 +8,8 @@ enum KeychainService {
8
8
9 @discardableResult
9 @discardableResult
10 static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
10 static func savePassword(_ password: String, for connectionID: UUID) -> Bool {
11− let account = connectionID.uuidString
12− deletePassword(for: connectionID)
13−
14 guard let data = password.data(using: .utf8) else { return false }
11 guard let data = password.data(using: .utf8) else { return false }
15− var query = baseQuery(for: connectionID)
12+ return upsert(data, query: baseQuery(for: connectionID), label: connectionID.uuidString)
16− query[kSecValueData as String] = data
17− let status = SecItemAdd(query as CFDictionary, nil)
18− if status != errSecSuccess {
19− logger.error("Failed to save password for \(account): OSStatus \(status)")
20− }
21− return status == errSecSuccess
22 }
13 }
23
14
24 static func loadPassword(for connectionID: UUID) -> String? {
15 static func loadPassword(for connectionID: UUID) -> String? {
@@ -51,14 +42,7 @@ enum KeychainService {
51
42
52 @discardableResult
43 @discardableResult
53 static func saveData(_ data: Data, for key: String) -> Bool {
44 static func saveData(_ data: Data, for key: String) -> Bool {
54− deleteData(for: key)
55− var query = baseQuery(for: key)
45+ upsert(data, query: baseQuery(for: key), label: key)
56− query[kSecValueData as String] = data
57− let status = SecItemAdd(query as CFDictionary, nil)
58− if status != errSecSuccess {
59− logger.error("Failed to save data for \(key): OSStatus \(status)")
60− }
61− return status == errSecSuccess
62 }
46 }
63
47
64 static func loadData(for key: String) -> Data? {
48 static func loadData(for key: String) -> Data? {
@@ -85,6 +69,32 @@ enum KeychainService {
85 return status == errSecSuccess || status == errSecItemNotFound
69 return status == errSecSuccess || status == errSecItemNotFound
86 }
70 }
87
71
72+ // MARK: - Upsert
73+
74+ /// Atomically stores `data` for the given query: tries `SecItemAdd` and,
75+ /// if the item already exists, falls back to `SecItemUpdate`. This avoids
76+ /// the delete-then-add race that could drop a write (and silently return
77+ /// `errSecDuplicateItem`) when two saves overlap.
78+ @discardableResult
79+ private static func upsert(_ data: Data, query: [String: Any], label: String) -> Bool {
80+ var addQuery = query
81+ addQuery[kSecValueData as String] = data
82+ let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
83+ if addStatus == errSecSuccess {
84+ return true
85+ }
86+ if addStatus == errSecDuplicateItem {
87+ let attributes = [kSecValueData as String: data]
88+ let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
89+ if updateStatus != errSecSuccess {
90+ logger.error("Failed to update item for \(label): OSStatus \(updateStatus)")
91+ }
92+ return updateStatus == errSecSuccess
93+ }
94+ logger.error("Failed to save item for \(label): OSStatus \(addStatus)")
95+ return false
96+ }
97+
88 // MARK: - Base queries
98 // MARK: - Base queries
89
99
90 private static func baseQuery(for connectionID: UUID) -> [String: Any] {
100 private static func baseQuery(for connectionID: UUID) -> [String: Any] {
ParseableViewer/Services/ParseableClient.swift+10−3
@@ -288,9 +288,16 @@ final class ParseableClient: Sendable {
288 return []
288 return []
289 }
289 }
290
290
291− // Peek at the first non-whitespace byte to choose the decoder path:
291+ // Peek at the first meaningful byte to choose the decoder path:
292− // '{' → wrapped response, '[' → direct array.
292+ // '{' → wrapped response, '[' → direct array. Skip a leading UTF-8 BOM
293+ // (0xEF 0xBB 0xBF) and ASCII whitespace so a BOM-prefixed wrapped
294+ // response isn't misrouted to the array decoder. (JSONDecoder itself
295+ // tolerates the BOM.)
296+ var sniff = responseData
297+ if sniff.starts(with: [0xEF, 0xBB, 0xBF]) {
298+ sniff = sniff.dropFirst(3)
299+ }
293− let firstByte = responseData.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
300+ let firstByte = sniff.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
294 if firstByte == 0x7B { // '{'
301 if firstByte == 0x7B { // '{'
295 do {
302 do {
296 return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
303 return try Self.jsonDecoder.decode(QueryResponse.self, from: responseData).records
ParseableViewer/ViewModels/QueryViewModel.swift+17−2
@@ -286,8 +286,22 @@ final class QueryViewModel {
286 filteredResults = results
286 filteredResults = results
287 } else {
287 } else {
288 let text = filterText
288 let text = filterText
289+ // The search-text cache is built asynchronously, so it may be empty
290+ // or stale when the filter changes. `zip` would silently truncate to
291+ // the shorter sequence and drop matching rows, so only use the cache
292+ // when it lines up with the current results; otherwise compute the
293+ // search text inline for correctness.
294+ if cachedSearchTexts.count == results.count {
289− filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
295+ filteredResults = zip(results, cachedSearchTexts).compactMap { record, searchText in
290− searchText.localizedCaseInsensitiveContains(text) ? record : nil
296+ searchText.localizedCaseInsensitiveContains(text) ? record : nil
297+ }
298+ } else {
299+ filteredResults = results.filter { record in
300+ record.values
301+ .map { $0.displayString }
302+ .joined(separator: " ")
303+ .localizedCaseInsensitiveContains(text)
304+ }
291 }
305 }
292 }
306 }
293 }
307 }
@@ -617,6 +631,7 @@ final class QueryViewModel {
617
631
618 func clearHistory() {
632 func clearHistory() {
619 queryHistory = []
633 queryHistory = []
634+ historyIsFull = false
620 Self.saveHistory([])
635 Self.saveHistory([])
621 }
636 }
622
637
ParseableViewer/Views/ConnectionSheet.swift+6−1
@@ -14,6 +14,10 @@ struct ConnectionSheet: View {
14 @State private var isSaving = false
14 @State private var isSaving = false
15 @State private var testResult: TestResult?
15 @State private var testResult: TestResult?
16 @State private var urlValidationError: String?
16 @State private var urlValidationError: String?
17+ /// For a new connection, holds the connection created on the first save so a
18+ /// retry (after a failed connect) updates it in place rather than appending a
19+ /// duplicate.
20+ @State private var createdConnection: ServerConnection?
17
21
18 enum TestResult {
22 enum TestResult {
19 case success(String)
23 case success(String)
@@ -160,12 +164,13 @@ struct ConnectionSheet: View {
160 guard !isSaving else { return }
164 guard !isSaving else { return }
161
165
162 let conn: ServerConnection
166 let conn: ServerConnection
163− if let existing = connection {
167+ if let existing = connection ?? createdConnection {
164 conn = ServerConnection(id: existing.id, name: name, url: url, username: username, password: password)
168 conn = ServerConnection(id: existing.id, name: name, url: url, username: username, password: password)
165 appState.updateConnection(conn)
169 appState.updateConnection(conn)
166 } else {
170 } else {
167 conn = ServerConnection(name: name, url: url, username: username, password: password)
171 conn = ServerConnection(name: name, url: url, username: username, password: password)
168 appState.addConnection(conn)
172 appState.addConnection(conn)
173+ createdConnection = conn
169 }
174 }
170
175
171 isSaving = true
176 isSaving = true
ParseableViewer/Views/LiveTailView.swift+24−1
@@ -13,6 +13,9 @@ struct LiveTailView: View {
13 @State private var columnWidths: [String: CGFloat] = [:]
13 @State private var columnWidths: [String: CGFloat] = [:]
14 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
14 @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
15 @State private var severityColumnSet: Set<String> = []
15 @State private var severityColumnSet: Set<String> = []
16+ /// Severity per entry id, precomputed on rebuild so it isn't recomputed for
17+ /// every visible row on each scroll/render.
18+ @State private var severityByID: [UUID: SeverityLevel] = [:]
16 @State private var liveTailSortTask: Task<Void, Never>?
19 @State private var liveTailSortTask: Task<Void, Never>?
17
20
18 var body: some View {
21 var body: some View {
@@ -277,13 +280,32 @@ struct LiveTailView: View {
277 }
280 }
278 }
281 }
279
282
283+ /// Computes widths for any visible columns that don't have one yet. Covers
284+ /// the case where switching to a stream with an identical column set doesn't
285+ /// fire `onChange(of: visibleColumns)`, which would otherwise leave widths
286+ /// empty (falling back to the default width) after `selectedStream` cleared
287+ /// them.
288+ private func ensureColumnWidths() {
289+ let records = viewModel.cachedFilteredRecords
290+ for col in viewModel.visibleColumns where columnWidths[col] == nil {
291+ columnWidths[col] = idealColumnWidth(for: col, records: records)
292+ }
293+ }
294+
280 private func rebuildSortedEntries() {
295 private func rebuildSortedEntries() {
296+ ensureColumnWidths()
281 let entries = viewModel.cachedFilteredEntries
297 let entries = viewModel.cachedFilteredEntries
282 let cols = viewModel.visibleColumns
298 let cols = viewModel.visibleColumns
283 let sevCols = buildSeverityColumnSet(columns: cols)
299 let sevCols = buildSeverityColumnSet(columns: cols)
300+ // Precompute severity per entry; sorting doesn't change the set.
301+ let severityMap = Dictionary(
302+ entries.map { ($0.id, extractSeverity(from: $0.record, severityColumns: sevCols)) },
303+ uniquingKeysWith: { first, _ in first }
304+ )
284 guard let col = sortColumn else {
305 guard let col = sortColumn else {
285 cachedSorted = entries
306 cachedSorted = entries
286 severityColumnSet = sevCols
307 severityColumnSet = sevCols
308+ severityByID = severityMap
287 return
309 return
288 }
310 }
289 liveTailSortTask?.cancel()
311 liveTailSortTask?.cancel()
@@ -301,6 +323,7 @@ struct LiveTailView: View {
301 guard !Task.isCancelled else { return }
323 guard !Task.isCancelled else { return }
302 cachedSorted = sorted
324 cachedSorted = sorted
303 severityColumnSet = sevCols
325 severityColumnSet = sevCols
326+ severityByID = severityMap
304 }
327 }
305 }
328 }
306
329
@@ -316,7 +339,7 @@ struct LiveTailView: View {
316 columnWidths: columnWidths,
339 columnWidths: columnWidths,
317 isSelected: selectedRecord == entry.record,
340 isSelected: selectedRecord == entry.record,
318 isAlternate: index % 2 == 1,
341 isAlternate: index % 2 == 1,
319− severity: extractSeverity(from: entry.record, severityColumns: severityColumnSet),
342+ severity: severityByID[entry.id] ?? .unknown,
320 severityColumns: severityColumnSet,
343 severityColumns: severityColumnSet,
321 wrapText: wrapText,
344 wrapText: wrapText,
322 onCellFilter: { column, value, exclude in
345 onCellFilter: { column, value, exclude in
ParseableViewer/Views/LogTableView.swift+9
@@ -259,6 +259,15 @@ struct LogTableView: View {
259 }
259 }
260 debouncedRebuildSort()
260 debouncedRebuildSort()
261 }
261 }
262+ .onChange(of: columns) { _, newColumns in
263+ // After a stream switch the previous sort column may not exist
264+ // in the new schema, which would silently produce a no-op sort
265+ // (every row compares as .null). Drop it so rows fall back to
266+ // their natural order.
267+ if let sortColumn, !newColumns.contains(sortColumn) {
268+ self.sortColumn = nil
269+ }
270+ }
262 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
271 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
263 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
272 .onChange(of: sortAscending) { _, _ in debouncedRebuildSort() }
264 .onAppear {
273 .onAppear {