From 2aebcf3ff707d046a83dc3b75c1fc275487204b5 Mon Sep 17 00:00:00 2001 From: Michael Yeack Date: Tue, 2 Jun 2026 12:41:00 -0700 Subject: [PATCH] Design-standard consistency + dead-code cleanup (review #7, #9, #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves three findings from docs/CODE_REVIEW_2026-06-02.md: #9 — .caption2 rule vs. reality. Bumped 16 user-facing TEXT sites from .caption2 to .caption (the documented floor): ContactListView (count, last-interaction date, metVia name, pill name, +N overflow), MergeContactPickerView (email), ActivityListView (calendar source + contact/group counts + names), SubscriptionView (BEST VALUE, per-period, renewal disclaimer), SettingsView (store-error help), BackupRestoreView (type/device badges). rules.md now documents the only two .caption2 exceptions — inline SF Symbol glyphs (trend arrows, chip icons, chevrons) and Canvas spatial labels (network-graph node names) — so rule and code agree. The 8 remaining .caption2 uses are exactly those two exempt categories. #7 — stale "Icon 1 = 48×48". rules.md described icon1Size as 48×48 with a larger Location row/header for prominence; the code has icon1Size = 36 and Locations use the standard 36×36 collection row (verified: no view renders a 48pt badge; icon1Size is referenced only by its own definition). Corrected the Icon 1 section to 36 and replaced the obsolete "Location Row Layout"/"Location Detail Header" sections with "Locations use the standard collection row/header." #12 — dead code. Deleted Blackbook/Services/LocalSyncServer.swift (718 lines). The class was never instantiated; the live sync handlers are in BlackbookServer/App/BackupServer.swift. Keeping it invited editing the wrong file (the exact confusion recorded in the work log for PR #36). Regenerated pbxproj. No page-doc (.cursor/pages) sync needed — none referenced .caption2. No behavior change; fonts shift one tier on metadata. iOS 206 + 13 tests pass; macOS builds. Recommend a quick on-device glance at the Contacts table + Activities rows since the bump can nudge row heights. Co-Authored-By: Claude Opus 4.8 --- .cursor/rules/rules.md | 79 +- .gitignore | 1 + Blackbook.xcodeproj/project.pbxproj | 4 - Blackbook/Services/LocalSyncServer.swift | 718 ------------------ .../Views/Activities/ActivityListView.swift | 8 +- .../Views/Contacts/ContactListView.swift | 10 +- .../Contacts/MergeContactPickerView.swift | 2 +- .../Views/Settings/BackupRestoreView.swift | 4 +- Blackbook/Views/Settings/SettingsView.swift | 2 +- .../Views/Settings/SubscriptionView.swift | 6 +- docs/CODE_REVIEW_2026-06-02.md | 5 + 11 files changed, 34 insertions(+), 805 deletions(-) delete mode 100644 Blackbook/Services/LocalSyncServer.swift diff --git a/.cursor/rules/rules.md b/.cursor/rules/rules.md index 1d1a31f..6910c5a 100644 --- a/.cursor/rules/rules.md +++ b/.cursor/rules/rules.md @@ -205,7 +205,11 @@ When adding new table or list views with column headers, use the **Header 2** st The app targets desktop (macOS) and tablet-class screens with ample real estate. Content must feel **comfortable and easy to scan**, not cramped. Follow these principles: -- **Minimum readable font is `.subheadline`** — never use `.caption2` for user-facing content. `.caption` is the absolute floor for metadata/timestamps. Use `.body` or larger for primary content. +- **Minimum readable font is `.subheadline`** — never use `.caption2` for user-facing **text content**. `.caption` is the absolute floor for metadata/timestamps. Use `.body` or larger for primary content. + - **`.caption2` is reserved for two non-text cases only, and nowhere else:** + 1. **Inline SF Symbol glyphs** sized inside a chip/badge/indicator — e.g. the score-trend arrow, a filter-chip's leading icon, a disclosure chevron. Here `.caption2` sizes an *icon*, not body text. + 2. **Spatial labels drawn on a `Canvas`** — e.g. network-graph node names, where a larger font overlaps adjacent nodes. + - All other labels (pill text, last-interaction dates, counts, badge/capsule labels, footnotes, disclaimers) use `.caption` or larger. Audited and enforced 2026-06-02. - **Section labels** (field headings like "Phone", "Met via") use `.subheadline.weight(.semibold)` — not `.caption`. - **Primary content values** (phone numbers, names, note bodies) use `.body` or larger. - **Titles on detail screens** use `.title.weight(.bold)` — not `.title2` or smaller. @@ -221,24 +225,13 @@ When in doubt, round **up** to the next font size / spacing tier. Small cramped ### Icon Styles -#### Icon 1 +#### Icon 1 (`icon1Size`) -Used for prominent, standalone icon badges in collection rows and detail headers where the icon is a key visual anchor (e.g. Location rows and headers). +`AppConstants.UI.icon1Size` is **36** — the single canonical collection-badge size, identical to the Row Icon Badges below. There is intentionally **one** badge size across Tags, Groups, and Locations. -```swift -Image(systemName: icon) - .font(.title3) - .foregroundStyle(.white) - .frame(width: AppConstants.UI.icon1Size, height: AppConstants.UI.icon1Size) - .background(color.gradient, in: RoundedRectangle(cornerRadius: 10)) -``` - -- **Size:** `AppConstants.UI.icon1Size` (48×48) -- **Corner radius:** 10 -- **Icon font:** `.title3` -- **Background:** `color.gradient` inside `RoundedRectangle` +> **History:** earlier drafts defined `icon1Size` as a larger **48×48** badge (cornerRadius 10, `.title3`) to give Locations extra prominence. The app standardized on 36×36 for all collections; `icon1Size` is now 36 and no view renders a 48pt badge. Do not reintroduce a 48×48 variant. (See `CLAUDE.md`: "Do not use `icon1Size` for larger icons.") -#### Row Icon Badges (Tag, Group, and any future collection-type rows) +#### Row Icon Badges (Tag, Group, Location, and any future collection-type rows) ```swift Image(systemName: icon) @@ -279,30 +272,9 @@ HStack(spacing: 12) { ### Location Row Layout -Location rows use **Icon 1** for the badge and **Header 1** for the name to give locations greater visual prominence. +**Locations use the standard Row Layout above** (36×36 badge, `.body.weight(.medium)` name) — identical to Tags and Groups. `LocationRowView` matches `TagRowView`/`GroupRowView` exactly. (An earlier draft gave Locations a larger Icon-1 / Header-1 row; that prominence was removed when the app standardized on a single 36×36 collection row.) -```swift -HStack(spacing: 12) { - // Icon 1 badge (48×48, see Icon 1) - VStack(alignment: .leading, spacing: 4) { - Text(name) - .font(.title.weight(.bold)) - Text("\(count) contact\(count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() -} -.padding(.vertical, 4) -``` - -- **Icon style:** Icon 1 (48×48) -- **Name font:** Header 1 — `.title.weight(.bold)` -- **Subtitle font:** `.caption`, `.secondary` foreground -- **VStack spacing:** 4 -- **Vertical padding:** 4 - -### Detail View Headers (Tag, Group, and future collection detail pages) +### Detail View Headers (Tag, Group, Location, and future collection detail pages) Use the same sizing as row icons — detail headers should feel like a natural extension of the list row, not a different component. @@ -337,34 +309,7 @@ Section { ### Location Detail Header -Location detail headers use **Icon 1** and **Header 1** to match the Location Row Layout. - -```swift -Section { - HStack(spacing: 12) { - Image(systemName: icon) - .font(.title3) - .foregroundStyle(.white) - .frame(width: AppConstants.UI.icon1Size, height: AppConstants.UI.icon1Size) - .background(color.color.gradient, in: RoundedRectangle(cornerRadius: 10)) - VStack(alignment: .leading, spacing: 4) { - Text(name) - .font(.title.weight(.bold)) - Text("\(count) contact\(count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - .padding(.vertical, 4) -} -``` - -- **Icon style:** Icon 1 (48×48, cornerRadius 10, `.title3` font) -- **Name font:** Header 1 — `.title.weight(.bold)` -- **Subtitle font:** `.caption` -- **VStack spacing:** 4 -- **Vertical padding:** 4 +**Locations use the standard Detail View Header above** (36×36 badge, cornerRadius 8, `.font(.body)`) — identical to Tag and Group detail headers. (An earlier draft prescribed an Icon-1 / Header-1 header for Locations; removed when the app standardized on the single 36×36 collection style.) ### Dashboard Contact Rows (Fading Relationships, Strongest Relationships, and future dashboard cards) diff --git a/.gitignore b/.gitignore index ab6657e..dbe0523 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ Pods/ # IDE .idea/ *.code-workspace +.claude/scheduled_tasks.lock diff --git a/Blackbook.xcodeproj/project.pbxproj b/Blackbook.xcodeproj/project.pbxproj index da2c8a4..f4887a2 100644 --- a/Blackbook.xcodeproj/project.pbxproj +++ b/Blackbook.xcodeproj/project.pbxproj @@ -79,7 +79,6 @@ 77D2B599AE22B4597BEBEEF8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A80CFB6F6BA1BDFBDE29CBD4 /* Assets.xcassets */; }; 79377F20F80D93BE4ED30678 /* LocationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47D954BD6E7D82202705588 /* LocationModelTests.swift */; }; 7D4533D903ED240ABB1F8386 /* Reminder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 367F11DDCEA44032C3674833 /* Reminder.swift */; }; - 7EAE471A3C0DEAE6F297474D /* LocalSyncServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45E53C6F0C7F7A6808F6EE /* LocalSyncServer.swift */; }; 7FD59E02711302FDE3365206 /* ContactLocationPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6331C25944672EB2F32005C /* ContactLocationPickerView.swift */; }; 819102BFD7CD40272EA76C97 /* InteractionViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 367B4A2B6BD57C86106A9103 /* InteractionViewModelTests.swift */; }; 82E24EDF90B4FDD321E60DC3 /* AuthGateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFF1714EC74CED7F5A86E00E /* AuthGateView.swift */; }; @@ -209,7 +208,6 @@ 4AB554426E75CDDDB20498E2 /* ServerModelContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerModelContainer.swift; sourceTree = ""; }; 4C58C1200FB5DE674799040E /* AIInsightsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIInsightsView.swift; sourceTree = ""; }; 4DCA116B785721C8942FD325 /* FeatureGatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureGatingTests.swift; sourceTree = ""; }; - 4E45E53C6F0C7F7A6808F6EE /* LocalSyncServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalSyncServer.swift; sourceTree = ""; }; 4FC80EA436D4FD929A77680C /* console.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = console.html; sourceTree = ""; }; 530F5990544546A519FE2390 /* IMessageSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IMessageSyncService.swift; sourceTree = ""; }; 535A0087C38A75EF251D28DF /* DeviceIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceIdentity.swift; sourceTree = ""; }; @@ -436,7 +434,6 @@ DC159C3515CC1B41D178A551 /* ContactSyncService.swift */, 2382E25FE6BDF339DA44560D /* GoogleCalendarService.swift */, D876B79CE3D85BAABBEBD128 /* LocalServerSyncService.swift */, - 4E45E53C6F0C7F7A6808F6EE /* LocalSyncServer.swift */, 682862BCC7F0F257DF35CEB8 /* NetworkGraphEngine.swift */, 1D046FD999BC660D58C26E32 /* PhotoStorageService.swift */, 56EBAC598F8585225B1F09E4 /* RelationshipScoreEngine.swift */, @@ -838,7 +835,6 @@ A83BFC0E65269E8E57FE2EF7 /* KeychainService.swift in Sources */, 9A18735D7C1D616279A77B5E /* LocalServerSyncService.swift in Sources */, AC83AF34CB61E8BE2848B4F1 /* LocalSyncProtocol.swift in Sources */, - 7EAE471A3C0DEAE6F297474D /* LocalSyncServer.swift in Sources */, 480F4AB91090F3D31E17B0D2 /* Location.swift in Sources */, 74A5E0E803230F6D72D2E75B /* LocationDetailView.swift in Sources */, D1D5EC721BB0E44AF9636690 /* LocationIconSuggestionView.swift in Sources */, diff --git a/Blackbook/Services/LocalSyncServer.swift b/Blackbook/Services/LocalSyncServer.swift deleted file mode 100644 index 5c972f5..0000000 --- a/Blackbook/Services/LocalSyncServer.swift +++ /dev/null @@ -1,718 +0,0 @@ -#if os(macOS) -import Foundation -import Network -import SwiftData -import os - -private let logger = Logger(subsystem: "com.blackbookdevelopment.app", category: "LocalSyncServer") - -/// Minimal HTTP request for the sync server. -private struct HTTPRequest { - let method: String - let path: String - let query: [String: String] - let headers: [String: String] - let body: Data? -} - -/// Runs a minimal HTTP sync server on the Mac (contacts pull/push, photos). macOS only. -final class LocalSyncServer: @unchecked Sendable { - private let password: String - private let photoDirectory: URL - private let container: ModelContainer - private let queue = DispatchQueue(label: "com.blackbookdevelopment.localsync.server") - private var listener: NWListener? - private var bonjourService: NetService? - private(set) var isRunning = false - private(set) var port: UInt16 = 0 - private var configuredPort: UInt16 = LocalSyncProtocol.defaultPort - private var isStopping = false - private var restartDelay: TimeInterval = 1.0 - private let maxRestartDelay: TimeInterval = 30.0 - - init(container: ModelContainer, password: String) { - self.container = container - self.password = password - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - let dir = appSupport.appendingPathComponent("Blackbook", isDirectory: true) - .appendingPathComponent("Photos", isDirectory: true) - self.photoDirectory = dir - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - } - - func start(port: UInt16 = LocalSyncProtocol.defaultPort) { - guard !isRunning else { return } - isStopping = false - configuredPort = port - let params = NWParameters.tcp - params.allowLocalEndpointReuse = true - - do { - let listener = try NWListener(using: params, on: NWEndpoint.Port(rawValue: port)!) - self.listener = listener - listener.stateUpdateHandler = { [weak self] state in - switch state { - case .ready: - self?.port = listener.port?.rawValue ?? 0 - self?.isRunning = true - self?.restartDelay = 1.0 - self?.publishBonjour(port: Int(self?.port ?? 0)) - logger.info("Local sync server listening on port \(self?.port ?? 0)") - case .failed(let error): - logger.error("Listener failed: \(error.localizedDescription)") - self?.scheduleRestart() - case .cancelled: - self?.isRunning = false - self?.port = 0 - case .waiting(let error): - logger.warning("Listener waiting: \(error.localizedDescription)") - default: - break - } - } - listener.newConnectionHandler = { [weak self] conn in - self?.handle(connection: conn) - } - listener.start(queue: queue) - } catch { - logger.error("Failed to start sync server: \(error.localizedDescription)") - scheduleRestart() - } - } - - func stop() { - isStopping = true - listener?.cancel() - listener = nil - bonjourService?.stop() - bonjourService = nil - isRunning = false - port = 0 - } - - private func scheduleRestart() { - guard !isStopping else { return } - listener?.cancel() - listener = nil - bonjourService?.stop() - bonjourService = nil - isRunning = false - port = 0 - let delay = restartDelay - restartDelay = min(restartDelay * 2, maxRestartDelay) - logger.info("Scheduling server restart in \(delay)s") - queue.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let self, !self.isStopping else { return } - self.start(port: self.configuredPort) - } - } - - private func publishBonjour(port: Int) { - let service = NetService(domain: "local.", type: LocalSyncProtocol.bonjourType, name: "Blackbook", port: Int32(port)) - bonjourService = service - service.publish() - } - - private func handle(connection: NWConnection) { - connection.start(queue: queue) - receiveRequest(connection: connection, accumulated: Data()) { [weak self] request in - guard let self else { return } - let response = self.handle(request: request) - self.sendResponse(response, on: connection) { - connection.cancel() - } - } - } - - private func receiveRequest(connection: NWConnection, accumulated: Data, completion: @escaping (HTTPRequest?) -> Void) { - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in - guard let self else { return } - var acc = accumulated - if let data = data, !data.isEmpty { acc.append(data) } - if error != nil || isComplete { - completion(self.parseRequest(acc)) - return - } - let separator = Data([0x0d, 0x0a, 0x0d, 0x0a]) - guard let range = acc.firstRange(of: separator) else { - self.receiveRequest(connection: connection, accumulated: acc, completion: completion) - return - } - let head = Data(acc[..= contentLength { - let body = Data(rest.prefix(contentLength)) - completion(HTTPRequest(method: req.method, path: req.path, query: req.query, headers: req.headers, body: body)) - return - } - self.receiveBody(connection: connection, accumulated: rest, remaining: contentLength - rest.count) { body in - completion(HTTPRequest(method: req.method, path: req.path, query: req.query, headers: req.headers, body: body)) - } - } - } - - private func receiveBody(connection: NWConnection, accumulated: Data, remaining: Int, completion: @escaping (Data?) -> Void) { - if remaining <= 0 { - completion(accumulated.isEmpty ? nil : accumulated) - return - } - connection.receive(minimumIncompleteLength: 1, maximumLength: min(remaining, 1048576)) { [weak self] data, _, _, error in - var acc = accumulated - let received = data?.count ?? 0 - if let data = data { acc.append(data) } - let newRemaining = remaining - received - if error != nil || newRemaining <= 0 { - completion(acc.isEmpty ? nil : acc) - } else { - self?.receiveBody(connection: connection, accumulated: acc, remaining: newRemaining, completion: completion) - } - } - } - - private func parseRequest(_ data: Data) -> HTTPRequest? { - let sep = Data([0x0d, 0x0a, 0x0d, 0x0a]) - guard let idx = data.firstRange(of: sep) else { return nil } - let head = data.prefix(upTo: idx.lowerBound) - let rest = data.suffix(from: idx.upperBound) - guard let req = parseRequestHead(Data(head)) else { return nil } - let len = (req.headers["content-length"] ?? req.headers["Content-Length"]).flatMap { Int($0) } ?? 0 - let body = len > 0 && rest.count >= len ? rest.prefix(len) : nil - return HTTPRequest(method: req.method, path: req.path, query: req.query, headers: req.headers, body: body.map { Data($0) }) - } - - private func parseRequestHead(_ data: Data) -> (method: String, path: String, query: [String: String], headers: [String: String])? { - guard let str = String(data: data, encoding: .utf8) else { return nil } - let lines = str.components(separatedBy: "\r\n") - guard let first = lines.first else { return nil } - let parts = first.split(separator: " ", maxSplits: 2) - guard parts.count >= 2 else { return nil } - let method = String(parts[0]) - let pathQuery = String(parts[1]) - let pathComps = pathQuery.split(separator: "?", maxSplits: 1) - let path = String(pathComps[0]) - var query: [String: String] = [:] - if pathComps.count > 1 { - for pair in pathComps[1].split(separator: "&") { - let kv = pair.split(separator: "=", maxSplits: 1) - if kv.count == 2 { - query[String(kv[0]).removingPercentEncoding ?? String(kv[0])] = String(kv[1]).removingPercentEncoding ?? String(kv[1]) - } - } - } - var headers: [String: String] = [:] - for line in lines.dropFirst() where line.contains(":") { - let sep = line.firstIndex(of: ":")! - let key = line[.. (status: Int, headers: [String: String], body: Data?) { - guard let request else { - return (400, [:], "Bad Request".data(using: .utf8)) - } - let auth = request.headers["x-sync-password"] ?? request.headers["X-Sync-Password"] - guard auth == password else { - return (401, [:], "Unauthorized".data(using: .utf8)) - } - - if request.method == "GET" && request.path.hasPrefix("/sync/changes") { - return handlePull(query: request.query) - } - if request.method == "POST" && request.path == "/sync/changes" { - return handlePush(body: request.body) - } - if request.method == "GET" && request.path.hasPrefix("/photo/") { - let id = String(request.path.dropFirst("/photo/".count)) - return handleGetPhoto(contactId: id) - } - if request.method == "POST" && request.path.hasPrefix("/photo/") { - let id = String(request.path.dropFirst("/photo/".count)) - return handlePostPhoto(contactId: id, body: request.body) - } - if request.method == "DELETE" && request.path.hasPrefix("/photo/") { - let id = String(request.path.dropFirst("/photo/".count)) - return handleDeletePhoto(contactId: id) - } - // Backup endpoints - if request.path.hasPrefix("/backups") { - guard let email = request.headers["x-user-email"], !email.isEmpty else { - return (400, [:], "Missing X-User-Email header".data(using: .utf8)) - } - return handleBackupRoute(request: request, userEmail: email) - } - // Heartbeat endpoint — lightweight check-in, written to a per-user JSONL log. - if request.method == "POST" && request.path == LocalSyncProtocol.Path.heartbeat { - guard let email = request.headers["x-user-email"], !email.isEmpty else { - return (400, [:], "Missing X-User-Email header".data(using: .utf8)) - } - return handleHeartbeat(body: request.body, userEmail: email) - } - return (404, [:], "Not Found".data(using: .utf8)) - } - - // MARK: - Heartbeat - - private var heartbeatsRoot: URL { - let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - .appendingPathComponent("Blackbook/Logs", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - private func userHeartbeatDir(_ email: String) -> URL { - let dir = heartbeatsRoot.appendingPathComponent(sanitizeEmail(email), isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - private static let heartbeatDateFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - f.timeZone = TimeZone(identifier: "UTC") - f.locale = Locale(identifier: "en_US_POSIX") - return f - }() - - /// POST /heartbeat — body is an arbitrary JSON object describing a client check-in. - /// We add a server-side `receivedAt` timestamp and append the line to - /// `/Blackbook/Logs//heartbeats-YYYY-MM-DD.jsonl`. - /// Returns 200 with `{"ok":true}` so the client can confirm round-trip success. - private func handleHeartbeat(body: Data?, userEmail: String) -> (status: Int, headers: [String: String], body: Data?) { - guard let body, - var json = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { - return (400, [:], "Invalid JSON body".data(using: .utf8)) - } - json["receivedAt"] = ISO8601DateFormatter().string(from: Date()) - json["email"] = userEmail - guard let line = try? JSONSerialization.data(withJSONObject: json) else { - return (500, [:], nil) - } - let day = Self.heartbeatDateFormatter.string(from: Date()) - let fileURL = userHeartbeatDir(userEmail).appendingPathComponent("heartbeats-\(day).jsonl") - do { - let handle: FileHandle - if FileManager.default.fileExists(atPath: fileURL.path) { - handle = try FileHandle(forWritingTo: fileURL) - try handle.seekToEnd() - } else { - FileManager.default.createFile(atPath: fileURL.path, contents: nil) - handle = try FileHandle(forWritingTo: fileURL) - } - defer { try? handle.close() } - try handle.write(contentsOf: line) - try handle.write(contentsOf: Data([0x0A])) // newline - logger.info("Heartbeat recorded for \(userEmail, privacy: .public)") - return (200, ["Content-Type": "application/json"], "{\"ok\":true}".data(using: .utf8)) - } catch { - logger.error("Heartbeat write failed: \(error.localizedDescription)") - return (500, [:], "Write failed".data(using: .utf8)) - } - } - - private func handlePull(query: [String: String]) -> (status: Int, headers: [String: String], body: Data?) { - guard let sinceStr = query["since"], - let since = ISO8601DateFormatter().date(from: sinceStr) else { - return (400, [:], "Missing or invalid since".data(using: .utf8)) - } - var json: [String: Any] = [:] - let sem = DispatchSemaphore(value: 0) - DispatchQueue.main.async { [weak self] in - guard let self else { sem.signal(); return } - let context = ModelContext(self.container) - do { - // Layer 0: Leaf entities (no foreign keys) - let tagPred = #Predicate { $0.updatedAt > since } - var tagDesc = FetchDescriptor(predicate: tagPred); tagDesc.fetchLimit = 2000 - json["tags"] = try context.fetch(tagDesc).map { ModelSyncApply.tagToDict($0) } - - let groupPred = #Predicate { $0.updatedAt > since } - var groupDesc = FetchDescriptor(predicate: groupPred); groupDesc.fetchLimit = 2000 - json["groups"] = try context.fetch(groupDesc).map { ModelSyncApply.groupToDict($0) } - - let locationPred = #Predicate { $0.updatedAt > since } - var locationDesc = FetchDescriptor(predicate: locationPred); locationDesc.fetchLimit = 2000 - json["locations"] = try context.fetch(locationDesc).map { ModelSyncApply.locationToDict($0) } - - let rejectedPred = #Predicate { $0.updatedAt > since } - var rejectedDesc = FetchDescriptor(predicate: rejectedPred); rejectedDesc.fetchLimit = 2000 - json["rejectedCalendarEvents"] = try context.fetch(rejectedDesc).map { ModelSyncApply.rejectedEventToDict($0) } - - // Layer 1: Activities (references Groups) - let activityPred = #Predicate { $0.updatedAt > since } - var activityDesc = FetchDescriptor(predicate: activityPred); activityDesc.fetchLimit = 2000 - json["activities"] = try context.fetch(activityDesc).map { ModelSyncApply.activityToDict($0) } - - // Layer 2: Contacts (references Tags, Groups, Locations, Activities) - let contactPred = #Predicate { $0.updatedAt > since } - var contactDesc = FetchDescriptor(predicate: contactPred); contactDesc.fetchLimit = 2000 - json["contacts"] = try context.fetch(contactDesc).map { ContactSyncApply.contactToDict($0) } - - // Layer 3: Child entities (reference Contacts) - let interactionPred = #Predicate { $0.updatedAt > since } - var interactionDesc = FetchDescriptor(predicate: interactionPred); interactionDesc.fetchLimit = 5000 - json["interactions"] = try context.fetch(interactionDesc).map { ModelSyncApply.interactionToDict($0) } - - let notePred = #Predicate { $0.updatedAt > since } - var noteDesc = FetchDescriptor(predicate: notePred); noteDesc.fetchLimit = 2000 - json["notes"] = try context.fetch(noteDesc).map { ModelSyncApply.noteToDict($0) } - - let reminderPred = #Predicate { $0.updatedAt > since } - var reminderDesc = FetchDescriptor(predicate: reminderPred); reminderDesc.fetchLimit = 2000 - json["reminders"] = try context.fetch(reminderDesc).map { ModelSyncApply.reminderToDict($0) } - - let relPred = #Predicate { $0.updatedAt > since } - var relDesc = FetchDescriptor(predicate: relPred); relDesc.fetchLimit = 2000 - json["contactRelationships"] = try context.fetch(relDesc).map { ModelSyncApply.contactRelationshipToDict($0) } - } catch { - logger.error("Pull fetch failed: \(error.localizedDescription)") - } - sem.signal() - } - sem.wait() - guard let data = try? JSONSerialization.data(withJSONObject: json) else { - return (500, [:], nil) - } - return (200, ["Content-Type": "application/json"], data) - } - - private func handlePush(body: Data?) -> (status: Int, headers: [String: String], body: Data?) { - guard let body, - let top = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { - return (400, [:], "Invalid JSON body".data(using: .utf8)) - } - let sem = DispatchSemaphore(value: 0) - var success = true - DispatchQueue.main.async { [weak self] in - guard let self else { sem.signal(); return } - let context = ModelContext(self.container) - do { - // Apply in dependency order: leaf entities first - - // Layer 0: Tags, Groups, Locations, RejectedCalendarEvents - if let tags = top["tags"] as? [[String: Any]] { - for dict in tags { try ModelSyncApply.applyRemoteTag(dict, to: context) } - } - if let groups = top["groups"] as? [[String: Any]] { - for dict in groups { try ModelSyncApply.applyRemoteGroup(dict, to: context) } - } - if let locations = top["locations"] as? [[String: Any]] { - for dict in locations { try ModelSyncApply.applyRemoteLocation(dict, to: context) } - } - if let events = top["rejectedCalendarEvents"] as? [[String: Any]] { - for dict in events { try ModelSyncApply.applyRemoteRejectedEvent(dict, to: context) } - } - - // Layer 1: Activities (references Groups) - if let activities = top["activities"] as? [[String: Any]] { - for dict in activities { try ModelSyncApply.applyRemoteActivity(dict, to: context) } - } - - // Layer 2: Contacts (references Tags, Groups, Locations, Activities) - if let contacts = top["contacts"] as? [[String: Any]] { - for dict in contacts { try ContactSyncApply.applyRemoteContact(dict, to: context) } - } - - // Layer 3: Child entities (reference Contacts) - if let interactions = top["interactions"] as? [[String: Any]] { - for dict in interactions { try ModelSyncApply.applyRemoteInteraction(dict, to: context) } - } - if let notes = top["notes"] as? [[String: Any]] { - for dict in notes { try ModelSyncApply.applyRemoteNote(dict, to: context) } - } - if let reminders = top["reminders"] as? [[String: Any]] { - for dict in reminders { try ModelSyncApply.applyRemoteReminder(dict, to: context) } - } - if let rels = top["contactRelationships"] as? [[String: Any]] { - for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: context) } - } - - // Handle deletes (structured per model type) - if let deletes = top["deletes"] as? [String: Any] { - try Self.applyDeletes(deletes, to: context) - } - // Legacy: flat deletes array for backward compatibility (contact-only) - if let legacyDeletes = top["deletes"] as? [String] { - for idStr in legacyDeletes { - guard let id = UUID(uuidString: idStr) else { continue } - let predicate = #Predicate { $0.id == id } - try context.delete(model: Contact.self, where: predicate) - } - } - - try context.save() - } catch { - logger.error("Push apply failed: \(error.localizedDescription)") - success = false - } - sem.signal() - } - sem.wait() - return (success ? 200 : 500, ["Content-Type": "application/json"], "{}".data(using: .utf8)) - } - - private static func applyDeletes(_ deletes: [String: Any], to context: ModelContext) throws { - if let ids = deletes["tags"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: Tag.self, where: pred) - } - } - if let ids = deletes["groups"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: Group.self, where: pred) - } - } - if let ids = deletes["locations"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: Location.self, where: pred) - } - } - if let ids = deletes["activities"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: Activity.self, where: pred) - } - } - if let ids = deletes["contacts"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: Contact.self, where: pred) - } - } - if let ids = deletes["interactions"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: Interaction.self, where: pred) - } - } - if let ids = deletes["notes"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: Note.self, where: pred) - } - } - if let ids = deletes["reminders"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: Reminder.self, where: pred) - } - } - if let ids = deletes["contactRelationships"] as? [String] { - for idStr in ids { - guard let id = UUID(uuidString: idStr) else { continue } - let pred = #Predicate { $0.id == id } - try context.delete(model: ContactRelationship.self, where: pred) - } - } - } - - private func handleGetPhoto(contactId: String) -> (status: Int, headers: [String: String], body: Data?) { - let fileURL = photoDirectory.appendingPathComponent("\(contactId).jpg") - guard FileManager.default.fileExists(atPath: fileURL.path), - let data = try? Data(contentsOf: fileURL) else { - return (404, [:], nil) - } - return (200, ["Content-Type": "image/jpeg"], data) - } - - private func handlePostPhoto(contactId: String, body: Data?) -> (status: Int, headers: [String: String], body: Data?) { - guard let body, !body.isEmpty else { return (400, [:], nil) } - let fileURL = photoDirectory.appendingPathComponent("\(contactId).jpg") - do { - try body.write(to: fileURL) - return (200, [:], nil) - } catch { - logger.error("Photo write failed: \(error.localizedDescription)") - return (500, [:], nil) - } - } - - private func handleDeletePhoto(contactId: String) -> (status: Int, headers: [String: String], body: Data?) { - let fileURL = photoDirectory.appendingPathComponent("\(contactId).jpg") - try? FileManager.default.removeItem(at: fileURL) - return (200, [:], nil) - } - - // MARK: - Backup Endpoints - - private var remoteBackupsDirectory: URL { - let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! - .appendingPathComponent("Blackbook/RemoteBackups", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - private func sanitizeEmail(_ email: String) -> String { - email.replacingOccurrences(of: "@", with: "_at_") - .replacingOccurrences(of: ".", with: "_") - } - - private func userBackupDir(_ email: String) -> URL { - remoteBackupsDirectory.appendingPathComponent(sanitizeEmail(email), isDirectory: true) - } - - private func handleBackupRoute(request: HTTPRequest, userEmail: String) -> (status: Int, headers: [String: String], body: Data?) { - let path = request.path - let fm = FileManager.default - - // GET /backups — list all backups for user - if request.method == "GET" && path == "/backups" { - let userDir = userBackupDir(userEmail) - guard let contents = try? fm.contentsOfDirectory(at: userDir, includingPropertiesForKeys: nil) else { - return (200, ["Content-Type": "application/json"], "[]".data(using: .utf8)) - } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - var backups: [BackupMetadata] = [] - for dir in contents where dir.hasDirectoryPath { - let metaURL = dir.appendingPathComponent("metadata.json") - guard let data = try? Data(contentsOf: metaURL), - let meta = try? decoder.decode(BackupMetadata.self, from: data), - meta.isComplete else { continue } - backups.append(meta) - } - backups.sort { $0.createdAt > $1.createdAt } - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - guard let json = try? encoder.encode(backups) else { - return (500, [:], nil) - } - return (200, ["Content-Type": "application/json"], json) - } - - // Parse path segments: /backups/{id}/... - let segments = path.split(separator: "/").map(String.init) // ["backups", id, ...] - guard segments.count >= 2 else { - return (404, [:], "Not Found".data(using: .utf8)) - } - let backupId = segments[1] - - // Validate backup ID (prevent path traversal) - guard !backupId.contains(".."), !backupId.contains("/") else { - return (400, [:], "Invalid backup ID".data(using: .utf8)) - } - - let backupDir = userBackupDir(userEmail).appendingPathComponent(backupId, isDirectory: true) - - // DELETE /backups/{id} - if request.method == "DELETE" && segments.count == 2 { - try? fm.removeItem(at: backupDir) - logger.info("Deleted remote backup \(backupId) for \(userEmail)") - return (200, [:], nil) - } - - // POST /backups/{id}/metadata - if request.method == "POST" && segments.count == 3 && segments[2] == "metadata" { - guard let body = request.body else { return (400, [:], "Missing body".data(using: .utf8)) } - try? fm.createDirectory(at: backupDir, withIntermediateDirectories: true) - do { - try body.write(to: backupDir.appendingPathComponent("metadata.json")) - logger.info("Saved metadata for backup \(backupId) from \(userEmail)") - return (200, [:], nil) - } catch { - return (500, [:], "Write failed".data(using: .utf8)) - } - } - - // POST /backups/{id}/file/{filename...} - if request.method == "POST" && segments.count >= 4 && segments[2] == "file" { - let filename = segments[3...].joined(separator: "/") - guard !filename.contains(".."), !filename.hasPrefix("/") else { - return (400, [:], "Invalid filename".data(using: .utf8)) - } - guard let body = request.body else { return (400, [:], "Missing body".data(using: .utf8)) } - let fileURL = backupDir.appendingPathComponent(filename) - try? fm.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) - do { - try body.write(to: fileURL) - logger.info("Saved backup file \(filename) for \(backupId)") - return (200, [:], nil) - } catch { - return (500, [:], "Write failed".data(using: .utf8)) - } - } - - // GET /backups/{id}/files — list files in backup - if request.method == "GET" && segments.count == 3 && segments[2] == "files" { - guard let enumerator = fm.enumerator(at: backupDir, includingPropertiesForKeys: [.fileSizeKey]) else { - return (404, [:], "Backup not found".data(using: .utf8)) - } - var files: [[String: Any]] = [] - while let url = enumerator.nextObject() as? URL { - guard !url.hasDirectoryPath else { continue } - let relativePath = url.path.replacingOccurrences(of: backupDir.path + "/", with: "") - let size = (try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0 - files.append(["name": relativePath, "size": size]) - } - guard let json = try? JSONSerialization.data(withJSONObject: ["files": files]) else { - return (500, [:], nil) - } - return (200, ["Content-Type": "application/json"], json) - } - - // GET /backups/{id}/file/{filename...} — download file - if request.method == "GET" && segments.count >= 4 && segments[2] == "file" { - let filename = segments[3...].joined(separator: "/") - guard !filename.contains(".."), !filename.hasPrefix("/") else { - return (400, [:], "Invalid filename".data(using: .utf8)) - } - let fileURL = backupDir.appendingPathComponent(filename) - guard let data = try? Data(contentsOf: fileURL) else { - return (404, [:], "File not found".data(using: .utf8)) - } - return (200, ["Content-Type": "application/octet-stream"], data) - } - - return (404, [:], "Not Found".data(using: .utf8)) - } - - // MARK: - Response Helpers - - private func sendResponse(_ response: (status: Int, headers: [String: String], body: Data?), on connection: NWConnection, completion: @escaping () -> Void) { - let statusLine = "HTTP/1.1 \(response.status) \(statusText(response.status))\r\n" - var headerLines = "" - for (k, v) in response.headers { - headerLines += "\(k): \(v)\r\n" - } - headerLines += "Content-Length: \(response.body?.count ?? 0)\r\n" - headerLines += "\r\n" - var data = (statusLine + headerLines).data(using: .utf8)! - if let body = response.body { data.append(body) } - connection.send(content: data, completion: .contentProcessed { _ in completion() }) - } - - private func statusText(_ code: Int) -> String { - switch code { - case 200: return "OK" - case 400: return "Bad Request" - case 401: return "Unauthorized" - case 404: return "Not Found" - case 500: return "Internal Server Error" - default: return "Unknown" - } - } -} - -#endif diff --git a/Blackbook/Views/Activities/ActivityListView.swift b/Blackbook/Views/Activities/ActivityListView.swift index 823be45..495d04d 100644 --- a/Blackbook/Views/Activities/ActivityListView.swift +++ b/Blackbook/Views/Activities/ActivityListView.swift @@ -115,7 +115,7 @@ struct ActivityListView: View { Spacer() if calendarService.isSignedIn { Text("From your Google Calendar") - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) } } @@ -269,17 +269,17 @@ struct ActivityRowView: View { VStack(alignment: .trailing, spacing: 2) { if !activity.contacts.isEmpty { Text(contactNames) - .font(.caption2) + .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) .multilineTextAlignment(.trailing) Text("\(activity.contacts.count) contact\(activity.contacts.count == 1 ? "" : "s")") - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) } if !activity.groups.isEmpty { Text("\(activity.groups.count) group\(activity.groups.count == 1 ? "" : "s")") - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) } } diff --git a/Blackbook/Views/Contacts/ContactListView.swift b/Blackbook/Views/Contacts/ContactListView.swift index 94f3eea..a4e710d 100644 --- a/Blackbook/Views/Contacts/ContactListView.swift +++ b/Blackbook/Views/Contacts/ContactListView.swift @@ -237,7 +237,7 @@ struct CollapsibleFilterSection: View { .foregroundStyle(.primary) if activeCount > 0 { Text("\(activeCount)") - .font(.caption2.weight(.bold)) + .font(.caption.weight(.bold)) .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -343,7 +343,7 @@ struct ContactRowView: View { ScoreBadgeView(score: contact.relationshipScore) } } - if let d = contact.lastInteractionDate { Text(d.relativeDescription).font(.caption2).foregroundStyle(.secondary) } + if let d = contact.lastInteractionDate { Text(d.relativeDescription).font(.caption).foregroundStyle(.secondary) } } } .padding(.vertical, 2) @@ -381,7 +381,7 @@ struct ContactRowView: View { Button { onColumnTap?(.metVia) } label: { if let metVia = contact.metVia { Text(metVia.displayName) - .font(.caption2.weight(.medium)) + .font(.caption.weight(.medium)) .lineLimit(1) .padding(.horizontal, 5) .padding(.vertical, 2) @@ -447,7 +447,7 @@ struct PillsColumnView: View { HStack(spacing: 3) { ForEach(pills.prefix(maxVisible)) { pill in Text(pill.name) - .font(.caption2.weight(.medium)) + .font(.caption.weight(.medium)) .lineLimit(1) .foregroundStyle(pill.color) .padding(.horizontal, 5) @@ -456,7 +456,7 @@ struct PillsColumnView: View { } if pills.count > maxVisible { Text("+\(pills.count - maxVisible)") - .font(.caption2.weight(.medium)) + .font(.caption.weight(.medium)) .foregroundStyle(.secondary) } } diff --git a/Blackbook/Views/Contacts/MergeContactPickerView.swift b/Blackbook/Views/Contacts/MergeContactPickerView.swift index 19217ff..af40df5 100644 --- a/Blackbook/Views/Contacts/MergeContactPickerView.swift +++ b/Blackbook/Views/Contacts/MergeContactPickerView.swift @@ -190,7 +190,7 @@ struct MergePrimarySelectionView: View { ScoreBadgeView(score: contact.relationshipScore) if !contact.emails.isEmpty { Text(contact.emails.first!) - .font(.caption2) + .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } diff --git a/Blackbook/Views/Settings/BackupRestoreView.swift b/Blackbook/Views/Settings/BackupRestoreView.swift index 0e463ba..b308ec1 100644 --- a/Blackbook/Views/Settings/BackupRestoreView.swift +++ b/Blackbook/Views/Settings/BackupRestoreView.swift @@ -234,7 +234,7 @@ struct BackupRestoreView: View { private func typeBadge(_ type: BackupType) -> some View { Text(type.displayName) - .font(.caption2.weight(.semibold)) + .font(.caption.weight(.semibold)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(badgeColor(for: type).opacity(0.15), in: Capsule()) @@ -243,7 +243,7 @@ struct BackupRestoreView: View { private func deviceBadge(_ name: String) -> some View { Text(name) - .font(.caption2.weight(.medium)) + .font(.caption.weight(.medium)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(.gray.opacity(0.15), in: Capsule()) diff --git a/Blackbook/Views/Settings/SettingsView.swift b/Blackbook/Views/Settings/SettingsView.swift index bad90df..e9f43dd 100644 --- a/Blackbook/Views/Settings/SettingsView.swift +++ b/Blackbook/Views/Settings/SettingsView.swift @@ -159,7 +159,7 @@ struct SettingsView: View { } if err.contains("default.store") || err.contains("couldn't be opened") { Text("The data store failed to open. In Xcode, go to Signing & Capabilities and select a development team, then rebuild. The app will fall back to local storage if CloudKit is unavailable.") - .font(.caption2) + .font(.caption) .foregroundStyle(.orange) } } diff --git a/Blackbook/Views/Settings/SubscriptionView.swift b/Blackbook/Views/Settings/SubscriptionView.swift index e559036..ed89e5a 100644 --- a/Blackbook/Views/Settings/SubscriptionView.swift +++ b/Blackbook/Views/Settings/SubscriptionView.swift @@ -144,7 +144,7 @@ struct SubscriptionView: View { .font(.headline) if isYearly { Text("BEST VALUE") - .font(.caption2.bold()) + .font(.caption.bold()) .padding(.horizontal, 6) .padding(.vertical, 2) .foregroundStyle(.white) @@ -160,7 +160,7 @@ struct SubscriptionView: View { Text(product.displayPrice) .font(.title3.bold()) Text(isYearly ? "per year" : "per month") - .font(.caption2) + .font(.caption) .foregroundStyle(.secondary) } } @@ -206,7 +206,7 @@ struct SubscriptionView: View { .buttonStyle(.plain) Text("Subscriptions renew automatically. You can cancel anytime in Settings.") - .font(.caption2) + .font(.caption) .foregroundStyle(.tertiary) .multilineTextAlignment(.center) .padding(.horizontal) diff --git a/docs/CODE_REVIEW_2026-06-02.md b/docs/CODE_REVIEW_2026-06-02.md index a771beb..29cf97d 100644 --- a/docs/CODE_REVIEW_2026-06-02.md +++ b/docs/CODE_REVIEW_2026-06-02.md @@ -7,6 +7,11 @@ Severity: **P1** correctness/data · **P2** standards/consistency · **P3** style/nits. +> **Remediation status (updated 2026-06-02, later same day).** Four follow-up PRs landed/are open: +> - **PR #46 (merged)** — finding #6 (partial): sync apply/conflict-resolution now has 11 unit tests (`SyncApplyTests`). Network-path integration still pending. +> - **This PR** — finding #9 (`.caption2`): 16 content sites bumped to `.caption`; rules.md now documents the two narrow exceptions (icon glyphs, canvas labels). Finding #7 (stale Icon-1 48×48): rules.md corrected to 36. Finding #12 (dead code): `LocalSyncServer.swift` deleted. +> - **Still open:** #5 (nameKey whitespace), #6 (network-path tests), #8 (EntityListRow adoption), #10 (file size), #11 (Button style). + --- ## 1. Overall health — strong