05/16 Fix bugs 2
This commit is contained in:
@@ -41,13 +41,30 @@ private struct _Envelope<T: Decodable & Sendable>: Decodable, Sendable {
|
||||
// local JSONDecoder to avoid Swift 6 actor-isolation errors.
|
||||
|
||||
// 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 {
|
||||
struct Tokens: Decodable, Sendable {
|
||||
let accessToken: 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 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 {
|
||||
@@ -107,7 +124,7 @@ actor APIClient {
|
||||
|
||||
// ── 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")
|
||||
|
||||
guard let imageData = FileManager.default.contents(atPath: localPath) else {
|
||||
@@ -133,9 +150,9 @@ actor APIClient {
|
||||
|
||||
let (data, response) = try await performRequest(req)
|
||||
|
||||
if shouldRefresh(response, retrying: false) {
|
||||
if shouldRefresh(response, retrying: retrying) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ final class LocalIssue {
|
||||
var syncRetryCount: Int
|
||||
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?
|
||||
|
||||
init(
|
||||
|
||||
@@ -36,6 +36,22 @@ class SyncManager: ObservableObject {
|
||||
private var pollTask: Task<Void, Never>? // replaces Timer — Task.sleep works correctly
|
||||
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()
|
||||
private init() {}
|
||||
|
||||
@@ -106,9 +122,7 @@ class SyncManager: ObservableObject {
|
||||
}
|
||||
|
||||
// Update the cursor to the newest notification's timestamp
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
let dates = notifications.compactMap { fmt.date(from: $0.createdAt) }
|
||||
let dates = notifications.compactMap { Self.isoFormatter.date(from: $0.createdAt) }
|
||||
if let newest = dates.max() {
|
||||
lastNotificationFetch = newest
|
||||
}
|
||||
@@ -424,8 +438,6 @@ class SyncManager: ObservableObject {
|
||||
if let sid = local.serverId { serverIdMap[sid] = local }
|
||||
}
|
||||
|
||||
let isoFmt = ISO8601DateFormatter()
|
||||
|
||||
for api in apiIssues {
|
||||
if let existing = serverIdMap[api.id] {
|
||||
// Update mutable fields on existing record
|
||||
@@ -455,11 +467,7 @@ class SyncManager: ObservableObject {
|
||||
serverPaths.append(contentsOf: api.resultPhotos)
|
||||
local.photoServerPaths = serverPaths
|
||||
if let ts = api.reportedAt,
|
||||
let date = isoFmt.date(from: ts) ?? {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
return f.date(from: ts)
|
||||
}() {
|
||||
let date = Self.isoFormatter.date(from: ts) {
|
||||
local.createdAt = date
|
||||
}
|
||||
context.insert(local)
|
||||
|
||||
Reference in New Issue
Block a user