05/16 Fix bugs 2

This commit is contained in:
Nguyen Ngo
2026-05-16 14:22:33 -04:00
parent 12d07cf250
commit 9c7aa72ff3
3 changed files with 45 additions and 13 deletions
+20 -3
View File
@@ -41,13 +41,30 @@ private struct _Envelope<T: Decodable & Sendable>: Decodable, Sendable {
// local JSONDecoder to avoid Swift 6 actor-isolation errors. // local JSONDecoder to avoid Swift 6 actor-isolation errors.
// Refresh-only envelope Sendable so it can cross actor boundaries in Swift 6. // Refresh-only envelope Sendable so it can cross actor boundaries in Swift 6.
// nonisolated init(from:) required on both types: without it the Swift 6 compiler
// infers @MainActor isolation on the Decodable conformance from the surrounding
// file context, producing "cannot be used in actor-isolated context" errors.
private struct _RefreshEnvelope: Decodable, Sendable { private struct _RefreshEnvelope: Decodable, Sendable {
struct Tokens: Decodable, Sendable { struct Tokens: Decodable, Sendable {
let accessToken: String let accessToken: String
let refreshToken: String let refreshToken: String
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
accessToken = try c.decode(String.self, forKey: .accessToken)
refreshToken = try c.decode(String.self, forKey: .refreshToken)
}
private enum CodingKeys: String, CodingKey { case accessToken, refreshToken }
} }
let ok: Bool let ok: Bool
let data: Tokens? let data: Tokens?
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
ok = try c.decode(Bool.self, forKey: .ok)
data = try? c.decode(Tokens.self, forKey: .data)
}
private enum CodingKeys: String, CodingKey { case ok, data }
} }
actor APIClient { actor APIClient {
@@ -107,7 +124,7 @@ actor APIClient {
// Photo Upload // Photo Upload
func uploadPhoto(localPath: String, entityType: String) async throws -> String { func uploadPhoto(localPath: String, entityType: String, retrying: Bool = false) async throws -> String {
let url = try buildURL("/api/v1/photos/upload") let url = try buildURL("/api/v1/photos/upload")
guard let imageData = FileManager.default.contents(atPath: localPath) else { guard let imageData = FileManager.default.contents(atPath: localPath) else {
@@ -133,9 +150,9 @@ actor APIClient {
let (data, response) = try await performRequest(req) let (data, response) = try await performRequest(req)
if shouldRefresh(response, retrying: false) { if shouldRefresh(response, retrying: retrying) {
let refreshed = await refreshAccessToken() let refreshed = await refreshAccessToken()
if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType) } if refreshed { return try await uploadPhoto(localPath: localPath, entityType: entityType, retrying: true) }
throw APIError.notAuthenticated throw APIError.notAuthenticated
} }
+7
View File
@@ -38,6 +38,13 @@ final class LocalIssue {
var syncRetryCount: Int var syncRetryCount: Int
var syncErrorMessage: String? var syncErrorMessage: String?
// Explicit inverse declared so SwiftData has an unambiguous relationship
// graph at schema-build time. Without it the relationship is implicit,
// which can cause migration warnings or incorrect cascade behaviour on some
// SwiftData versions. The deleteRule is .nullify (default) deleting the
// parent inspection cascades via LocalInspection.localIssues; this side
// only nullifies the back-pointer.
@Relationship(deleteRule: .nullify, inverse: \LocalInspection.localIssues)
var inspection: LocalInspection? var inspection: LocalInspection?
init( init(
+18 -10
View File
@@ -36,6 +36,22 @@ class SyncManager: ObservableObject {
private var pollTask: Task<Void, Never>? // replaces Timer Task.sleep works correctly private var pollTask: Task<Void, Never>? // replaces Timer Task.sleep works correctly
private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds private let pollInterval: UInt64 = 60_000_000_000 // 60 seconds in nanoseconds
// Shared date formatters
// DateFormatter init is expensive allocating one per poll call or per
// issue would add measurable overhead at sync time. These are created
// once and reused across all calls. Both are nonisolated statics so they
// can be read from any context without actor-hopping.
//
// isoFormatter parses/formats ISO 8601 strings from the server API
// e.g. "2026-05-01T14:30:00"
// notifFormatter same format, used to advance the notification poll cursor
nonisolated static let isoFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return f
}()
static let shared = SyncManager() static let shared = SyncManager()
private init() {} private init() {}
@@ -106,9 +122,7 @@ class SyncManager: ObservableObject {
} }
// Update the cursor to the newest notification's timestamp // Update the cursor to the newest notification's timestamp
let fmt = DateFormatter() let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) }
fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
let dates = notifications.compactMap { fmt.date(from: $0.createdAt) }
if let newest = dates.max() { if let newest = dates.max() {
lastNotificationFetch = newest lastNotificationFetch = newest
} }
@@ -424,8 +438,6 @@ class SyncManager: ObservableObject {
if let sid = local.serverId { serverIdMap[sid] = local } if let sid = local.serverId { serverIdMap[sid] = local }
} }
let isoFmt = ISO8601DateFormatter()
for api in apiIssues { for api in apiIssues {
if let existing = serverIdMap[api.id] { if let existing = serverIdMap[api.id] {
// Update mutable fields on existing record // Update mutable fields on existing record
@@ -455,11 +467,7 @@ class SyncManager: ObservableObject {
serverPaths.append(contentsOf: api.resultPhotos) serverPaths.append(contentsOf: api.resultPhotos)
local.photoServerPaths = serverPaths local.photoServerPaths = serverPaths
if let ts = api.reportedAt, if let ts = api.reportedAt,
let date = isoFmt.date(from: ts) ?? { let date = Self.isoFormatter.date(from: ts) {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return f.date(from: ts)
}() {
local.createdAt = date local.createdAt = date
} }
context.insert(local) context.insert(local)