feat: Ipad app production readiness, Colony orchestration, Social posting
This commit is contained in:
@@ -2,8 +2,7 @@ import Foundation
|
||||
import Security
|
||||
|
||||
/// Central app configuration.
|
||||
/// Build settings remain the fallback, but production installs should prefer
|
||||
/// runtime configuration stored on-device.
|
||||
/// Enterprise installs must use runtime configuration stored on-device.
|
||||
enum AppConfig {
|
||||
private static let runtimeBaseURLKey = "velocity.runtime.base_url"
|
||||
private static let runtimeDreamWeaverBaseURLKey = "velocity.runtime.dream_weaver_base_url"
|
||||
@@ -42,10 +41,6 @@ enum AppConfig {
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
private static func value(for key: String) -> String? {
|
||||
parsedValue(from: Bundle.main.infoDictionary, key: key)
|
||||
}
|
||||
|
||||
private static func sanitizedValue(_ raw: String?, key: String) -> String? {
|
||||
guard let raw else {
|
||||
return nil
|
||||
@@ -59,7 +54,7 @@ enum AppConfig {
|
||||
|
||||
/// Base URL for the Velocity backend / gateway.
|
||||
static var baseURL: String {
|
||||
runtimeBaseURL ?? value(for: "BASE_URL") ?? "https://velocity.desineuron.in/api"
|
||||
runtimeBaseURL ?? SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
|
||||
/// Dedicated Dream Weaver gateway endpoint when configured; otherwise
|
||||
@@ -76,19 +71,19 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
static var dreamWeaverAPIKey: String? {
|
||||
runtimeDreamWeaverAPIKey ?? value(for: "DREAM_WEAVER_API_KEY")
|
||||
runtimeDreamWeaverAPIKey
|
||||
}
|
||||
|
||||
static var apiEmail: String? {
|
||||
runtimeEmail ?? value(for: "API_EMAIL")
|
||||
runtimeEmail
|
||||
}
|
||||
|
||||
static var apiPassword: String? {
|
||||
runtimePassword ?? value(for: "API_PASSWORD")
|
||||
runtimePassword
|
||||
}
|
||||
|
||||
static var apiBearerToken: String? {
|
||||
runtimeBearerToken ?? value(for: "API_BEARER_TOKEN")
|
||||
runtimeBearerToken
|
||||
}
|
||||
|
||||
static var apiAccessToken: String? {
|
||||
@@ -132,7 +127,7 @@ enum AppConfig {
|
||||
email: apiEmail,
|
||||
hasPassword: apiPassword != nil,
|
||||
hasBearerToken: apiBearerToken != nil,
|
||||
source: hasStoredRuntimeConfiguration ? .secureDeviceStorage : .buildConfiguration
|
||||
source: .secureDeviceStorage
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,23 +139,15 @@ enum AppConfig {
|
||||
password: String?,
|
||||
bearerToken: String?
|
||||
) throws {
|
||||
UserDefaults.standard.set(baseURL, forKey: runtimeBaseURLKey)
|
||||
|
||||
if let dreamWeaverBaseURL {
|
||||
UserDefaults.standard.set(dreamWeaverBaseURL, forKey: runtimeDreamWeaverBaseURLKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
}
|
||||
|
||||
if let email {
|
||||
UserDefaults.standard.set(email, forKey: runtimeEmailKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
}
|
||||
|
||||
try storeSecret(baseURL, account: runtimeBaseURLKey)
|
||||
try storeSecret(dreamWeaverBaseURL, account: runtimeDreamWeaverBaseURLKey)
|
||||
try storeSecret(email, account: runtimeEmailKey)
|
||||
try storeSecret(dreamWeaverAPIKey, account: runtimeDreamWeaverAPIKeyKey)
|
||||
try storeSecret(password, account: runtimePasswordKey)
|
||||
try storeSecret(bearerToken, account: runtimeBearerTokenKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
try clearStoredAccessToken()
|
||||
}
|
||||
|
||||
@@ -168,6 +155,9 @@ enum AppConfig {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
try deleteSecret(account: runtimeBaseURLKey)
|
||||
try deleteSecret(account: runtimeDreamWeaverBaseURLKey)
|
||||
try deleteSecret(account: runtimeEmailKey)
|
||||
try deleteSecret(account: runtimeDreamWeaverAPIKeyKey)
|
||||
try deleteSecret(account: runtimePasswordKey)
|
||||
try deleteSecret(account: runtimeBearerTokenKey)
|
||||
@@ -186,16 +176,29 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
private static var runtimeBaseURL: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeBaseURLKey), key: runtimeBaseURLKey)
|
||||
canonicalizedBackendBaseURL(
|
||||
sanitizedValue(secret(account: runtimeBaseURLKey), key: runtimeBaseURLKey)
|
||||
)
|
||||
}
|
||||
|
||||
private static func canonicalizedBackendBaseURL(_ value: String?) -> String? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.caseInsensitiveCompare(SessionConfigurationDefaults.legacyVelocityWebBaseURL) == .orderedSame {
|
||||
return SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static var configuredDreamWeaverBaseURL: String? {
|
||||
runtimeDreamWeaverBaseURL ?? value(for: "DREAM_WEAVER_BASE_URL")
|
||||
runtimeDreamWeaverBaseURL
|
||||
}
|
||||
|
||||
private static var runtimeDreamWeaverBaseURL: String? {
|
||||
sanitizedValue(
|
||||
UserDefaults.standard.string(forKey: runtimeDreamWeaverBaseURLKey),
|
||||
secret(account: runtimeDreamWeaverBaseURLKey),
|
||||
key: runtimeDreamWeaverBaseURLKey
|
||||
)
|
||||
}
|
||||
@@ -205,7 +208,7 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
private static var runtimeEmail: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeEmailKey), key: runtimeEmailKey)
|
||||
sanitizedValue(secret(account: runtimeEmailKey), key: runtimeEmailKey)
|
||||
}
|
||||
|
||||
private static var runtimePassword: String? {
|
||||
|
||||
@@ -12,6 +12,12 @@ enum SessionConfigurationSource: String {
|
||||
case secureDeviceStorage = "Secure device storage"
|
||||
}
|
||||
|
||||
enum SessionConfigurationDefaults {
|
||||
static let productionBaseURL = "https://api.desineuron.in/api"
|
||||
static let legacyVelocityWebBaseURL = "https://velocity.desineuron.in/api"
|
||||
static let dreamWeaverBaseURL = "https://dreamweaver.desineuron.in"
|
||||
}
|
||||
|
||||
struct AppSessionConfiguration: Equatable {
|
||||
let baseURL: String
|
||||
let dreamWeaverBaseURL: String
|
||||
@@ -89,7 +95,13 @@ struct SessionConfigurationDraft: Equatable {
|
||||
}
|
||||
|
||||
var normalizedBaseURL: String? {
|
||||
Self.normalizedHTTPSOrigin(from: trimmedBaseURL)
|
||||
guard let normalized = Self.normalizedHTTPSOrigin(from: trimmedBaseURL) else {
|
||||
return nil
|
||||
}
|
||||
if normalized.caseInsensitiveCompare(SessionConfigurationDefaults.legacyVelocityWebBaseURL) == .orderedSame {
|
||||
return SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
var trimmedDreamWeaverBaseURL: String? {
|
||||
@@ -118,7 +130,7 @@ struct SessionConfigurationDraft: Equatable {
|
||||
}
|
||||
|
||||
guard normalizedBaseURL != nil else {
|
||||
errors.append("Backend endpoint must be an HTTPS API base like https://velocity.desineuron.in/api.")
|
||||
errors.append("Backend endpoint must be an HTTPS API base like \(SessionConfigurationDefaults.productionBaseURL).")
|
||||
return errors
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ final class SessionStore {
|
||||
|
||||
func reloadFromPersistedConfiguration() {
|
||||
currentConfiguration = AppConfig.currentSessionConfiguration()
|
||||
draftBaseURL = currentConfiguration.baseURL
|
||||
draftDreamWeaverBaseURL = persistedDreamWeaverDraftValue
|
||||
draftBaseURL = trimmedNonEmpty(currentConfiguration.baseURL) ?? SessionConfigurationDefaults.productionBaseURL
|
||||
draftDreamWeaverBaseURL = trimmedNonEmpty(persistedDreamWeaverDraftValue) ?? SessionConfigurationDefaults.dreamWeaverBaseURL
|
||||
draftDreamWeaverAPIKey = ""
|
||||
draftAuthMode = currentConfiguration.authMode
|
||||
draftEmail = currentConfiguration.email ?? ""
|
||||
@@ -88,6 +88,11 @@ final class SessionStore {
|
||||
baselineEmail = currentConfiguration.email
|
||||
}
|
||||
|
||||
func markDraftEdited() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
}
|
||||
|
||||
func discardDraftChanges() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
@@ -185,6 +190,11 @@ final class SessionStore {
|
||||
currentConfiguration.usesDedicatedDreamWeaverBaseURL ? currentConfiguration.dreamWeaverBaseURL : ""
|
||||
}
|
||||
|
||||
private func trimmedNonEmpty(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func verificationStatusMessage(
|
||||
successPrefix: String,
|
||||
backendRefreshError: String?,
|
||||
|
||||
@@ -18,29 +18,71 @@ final class ComfyClient {
|
||||
|
||||
// MARK: - Health Check
|
||||
|
||||
/// Call on app launch to confirm the Dream Weaver gateway is reachable
|
||||
/// and the Dream Weaver routes are actually mounted behind it.
|
||||
func checkHealth() async -> Bool {
|
||||
func checkReadiness() async -> DreamWeaverReadiness {
|
||||
do {
|
||||
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
|
||||
request.timeoutInterval = 30.0
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
return false
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway offline",
|
||||
detail: "Dream Weaver gateway did not return a healthy /health response."
|
||||
)
|
||||
}
|
||||
|
||||
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
|
||||
guard ["ok", "healthy"].contains(json.status.lowercased()) else {
|
||||
return false
|
||||
let health = try JSONDecoder().decode(HealthResponse.self, from: data)
|
||||
guard ["ok", "healthy"].contains(health.status.lowercased()) else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway unhealthy",
|
||||
detail: "Dream Weaver gateway reported status: \(health.status)."
|
||||
)
|
||||
}
|
||||
guard health.comfyui != false else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "ComfyUI offline",
|
||||
detail: "The gateway is online, but ComfyUI/GPU is not reachable."
|
||||
)
|
||||
}
|
||||
guard health.checkpointReady != false else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Checkpoint missing",
|
||||
detail: "ComfyUI is online, but no compatible Dream Weaver checkpoint is available."
|
||||
)
|
||||
}
|
||||
guard try await probeDreamWeaverRoute() else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Route not mounted",
|
||||
detail: "The /dream-weaver route family is not mounted behind the configured gateway."
|
||||
)
|
||||
}
|
||||
|
||||
return try await probeDreamWeaverRoute()
|
||||
return DreamWeaverReadiness(
|
||||
isReady: true,
|
||||
label: "Ready",
|
||||
detail: "Gateway, Dream Weaver route, ComfyUI, and checkpoint checks passed."
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway offline",
|
||||
detail: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Call on app launch to confirm the Dream Weaver gateway is reachable
|
||||
/// and the Dream Weaver routes are actually mounted behind it.
|
||||
func checkHealth() async -> Bool {
|
||||
let readiness = await checkReadiness()
|
||||
return readiness.isReady
|
||||
}
|
||||
|
||||
// MARK: - Main Generation Pipeline
|
||||
|
||||
/// Full pipeline: upload → queue → poll → download.
|
||||
@@ -49,6 +91,10 @@ final class ComfyClient {
|
||||
/// - roomType: The type of room being designed (e.g. "bedroom", "living_room").
|
||||
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
|
||||
func generateImage(source: UIImage, roomType: String, keywords: String) async throws -> UIImage {
|
||||
try await generateImageResult(source: source, roomType: roomType, keywords: keywords).image
|
||||
}
|
||||
|
||||
func generateImageResult(source: UIImage, roomType: String, keywords: String) async throws -> DreamWeaverGenerationResult {
|
||||
let normalised = source.fixedOrientation()
|
||||
let resized = normalised.resizedSquare(to: 1024)
|
||||
guard let imageData = resized.jpegData(compressionQuality: 0.85) else {
|
||||
@@ -62,7 +108,8 @@ final class ComfyClient {
|
||||
let resultURL = try await pollUntilReady(job: job)
|
||||
|
||||
// 3. Download result PNG
|
||||
return try await downloadResult(from: resultURL)
|
||||
let image = try await downloadResult(from: resultURL)
|
||||
return DreamWeaverGenerationResult(image: image, resultURL: resultURL)
|
||||
}
|
||||
|
||||
// MARK: - Step 1: POST /dream-weaver
|
||||
@@ -266,9 +313,48 @@ struct JobStatus: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthResponse: Codable {
|
||||
struct DreamWeaverReadiness: Equatable {
|
||||
let isReady: Bool
|
||||
let label: String
|
||||
let detail: String
|
||||
}
|
||||
|
||||
struct DreamWeaverGenerationResult {
|
||||
let image: UIImage
|
||||
let resultURL: URL
|
||||
}
|
||||
|
||||
struct HealthResponse: Decodable {
|
||||
let status: String
|
||||
let comfyui: Bool?
|
||||
let checkpointReady: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case comfyui
|
||||
case checkpointReady = "checkpoint_ready"
|
||||
case preferredCheckpointAvailable = "preferred_checkpoint_available"
|
||||
case checkpointAvailable = "checkpoint_available"
|
||||
case hasCheckpoint = "has_checkpoint"
|
||||
case gpuReady = "gpu_ready"
|
||||
}
|
||||
|
||||
init(status: String, comfyui: Bool?, checkpointReady: Bool?) {
|
||||
self.status = status
|
||||
self.comfyui = comfyui
|
||||
self.checkpointReady = checkpointReady
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
status = try container.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
|
||||
comfyui = try container.decodeIfPresent(Bool.self, forKey: .comfyui)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .gpuReady)
|
||||
checkpointReady = try container.decodeIfPresent(Bool.self, forKey: .checkpointReady)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .preferredCheckpointAvailable)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .checkpointAvailable)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .hasCheckpoint)
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamWeaverErrorResponse: Codable {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct VelocityVaultShareAsset {
|
||||
let leadId: String?
|
||||
let assetName: String
|
||||
let assetType: String
|
||||
let storagePath: String?
|
||||
|
||||
var isShareable: Bool {
|
||||
leadId?.trimmedNonEmpty != nil && storagePath?.trimmedNonEmpty != nil
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
var velocityStoragePath: String {
|
||||
let cleaned = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if cleaned.hasPrefix("assets/") {
|
||||
return String(cleaned.dropFirst("assets/".count))
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func vaultSwipeToShare(asset: VelocityVaultShareAsset?) -> some View {
|
||||
modifier(VaultSwipeToShareModifier(asset: asset))
|
||||
}
|
||||
}
|
||||
|
||||
private struct VaultSwipeToShareModifier: ViewModifier {
|
||||
@State private var appStore = AppStore.shared
|
||||
let asset: VelocityVaultShareAsset?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
ThreeFingerSwipeUpRecognizer {
|
||||
Task { await shareAsset() }
|
||||
}
|
||||
.allowsHitTesting(asset != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func shareAsset() async {
|
||||
guard let asset else { return }
|
||||
guard let leadId = asset.leadId?.trimmedNonEmpty,
|
||||
let storagePath = asset.storagePath?.trimmedNonEmpty else {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = "Vault share requires a backend lead and stored asset path."
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let threadId = appStore.activeCommunicationsThreadID?.trimmedNonEmpty else {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = "Open a Communications thread before using Vault Swipe-to-Share."
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
do {
|
||||
let link = try await VelocityAPIClient.shared.generateVaultLink(
|
||||
leadId: leadId,
|
||||
assetName: asset.assetName,
|
||||
assetType: asset.assetType,
|
||||
storagePath: storagePath
|
||||
)
|
||||
_ = try await VelocityAPIClient.shared.sendCommsMessage(
|
||||
threadId: threadId,
|
||||
body: "Secure Velocity Vault link: \(link.vaultUrl)"
|
||||
)
|
||||
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.82)) {
|
||||
appStore.vaultShareMessage = "Vault link shared to the active thread."
|
||||
appStore.vaultShareError = nil
|
||||
}
|
||||
} catch {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = error.localizedDescription
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThreeFingerSwipeUpRecognizer: UIViewRepresentable {
|
||||
let onSwipe: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView(frame: .zero)
|
||||
view.backgroundColor = .clear
|
||||
let recognizer = UISwipeGestureRecognizer(
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.didSwipe(_:))
|
||||
)
|
||||
recognizer.direction = .up
|
||||
recognizer.numberOfTouchesRequired = 3
|
||||
view.addGestureRecognizer(recognizer)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onSwipe: onSwipe)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
let onSwipe: () -> Void
|
||||
|
||||
init(onSwipe: @escaping () -> Void) {
|
||||
self.onSwipe = onSwipe
|
||||
}
|
||||
|
||||
@objc func didSwipe(_ recognizer: UISwipeGestureRecognizer) {
|
||||
guard recognizer.state == .ended else { return }
|
||||
onSwipe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum AppStoreRefreshPolicy {
|
||||
/// Native iPad surfaces refresh on initial view load, pull-to-refresh, and
|
||||
/// explicit user mutations. View-local repeating timers are intentionally
|
||||
/// avoided so AppStore can coalesce in-flight refreshes and hydrate mobile
|
||||
/// edge state through one bulk request.
|
||||
static let timerDrivenRefreshesEnabled = false
|
||||
|
||||
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
|
||||
/// are based on the same production property slice by default.
|
||||
static let inventoryPropertyLimit = 100
|
||||
@@ -9,8 +15,9 @@ enum AppStoreRefreshPolicy {
|
||||
/// the operator's active task load on iPad surfaces.
|
||||
static let canonicalTaskLimit = 50
|
||||
|
||||
/// iPad surfaces only render a small operator-focused timeline, so keep the
|
||||
/// lead-event hydration set intentionally narrower than WebOS.
|
||||
/// Lead timelines are hydrated through the mobile-edge bulk endpoint. Keep
|
||||
/// the selected lead set bounded so every shared refresh remains one
|
||||
/// predictable backend call rather than N per-lead calls.
|
||||
static let leadTimelineHydrationLimit = 6
|
||||
|
||||
/// Fetch enough recent communication context for the visible iPad rails
|
||||
|
||||
220
iOS/velocity-ipad/velocity/Core/State/OfflineReplayStore.swift
Normal file
220
iOS/velocity-ipad/velocity/Core/State/OfflineReplayStore.swift
Normal file
@@ -0,0 +1,220 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
struct OfflineReplayRecord: Identifiable {
|
||||
let id: String
|
||||
let kind: String
|
||||
let operation: String
|
||||
let targetID: String?
|
||||
let payload: Data
|
||||
let queuedAt: Date
|
||||
let attemptCount: Int
|
||||
let lastAttemptAt: Date?
|
||||
let lastError: String?
|
||||
}
|
||||
|
||||
actor OfflineReplayStore {
|
||||
static let shared = OfflineReplayStore()
|
||||
|
||||
private enum Schema {
|
||||
static let entityName = "OfflineReplayItem"
|
||||
}
|
||||
|
||||
private let container: NSPersistentContainer
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init() {
|
||||
let model = Self.makeModel()
|
||||
container = NSPersistentContainer(name: "VelocityOfflineReplay", managedObjectModel: model)
|
||||
|
||||
let applicationSupport = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first ?? FileManager.default.temporaryDirectory
|
||||
let directory = applicationSupport.appendingPathComponent("Velocity", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
let description = NSPersistentStoreDescription(
|
||||
url: directory.appendingPathComponent("OfflineReplay.sqlite")
|
||||
)
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
#if os(iOS)
|
||||
description.setOption(
|
||||
FileProtectionType.complete.rawValue as NSString,
|
||||
forKey: NSPersistentStoreFileProtectionKey
|
||||
)
|
||||
#endif
|
||||
container.persistentStoreDescriptions = [description]
|
||||
|
||||
var loadError: Error?
|
||||
container.loadPersistentStores { _, error in
|
||||
loadError = error
|
||||
}
|
||||
if let loadError {
|
||||
assertionFailure("Velocity offline replay store failed to load: \(loadError.localizedDescription)")
|
||||
}
|
||||
|
||||
context = container.newBackgroundContext()
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
}
|
||||
|
||||
func enqueue(kind: String, operation: String, targetID: String?, payload: Data) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
if let targetID {
|
||||
let existing = Self.fetchManagedObjects(kind: kind, targetID: targetID, in: context)
|
||||
existing.forEach(context.delete)
|
||||
}
|
||||
|
||||
guard let entity = NSEntityDescription.entity(forEntityName: Schema.entityName, in: context) else {
|
||||
return
|
||||
}
|
||||
let item = NSManagedObject(entity: entity, insertInto: context)
|
||||
item.setValue(UUID().uuidString, forKey: "id")
|
||||
item.setValue(kind, forKey: "kind")
|
||||
item.setValue(operation, forKey: "operation")
|
||||
item.setValue(targetID, forKey: "targetID")
|
||||
item.setValue(payload, forKey: "payload")
|
||||
item.setValue(Date(), forKey: "queuedAt")
|
||||
item.setValue(0, forKey: "attemptCount")
|
||||
item.setValue(nil, forKey: "lastAttemptAt")
|
||||
item.setValue(nil, forKey: "lastError")
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func pendingRecords(limit: Int = 100) -> [OfflineReplayRecord] {
|
||||
let context = context
|
||||
var records: [OfflineReplayRecord] = []
|
||||
context.performAndWait {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(key: "queuedAt", ascending: true)
|
||||
]
|
||||
request.fetchLimit = limit
|
||||
let items = (try? context.fetch(request)) ?? []
|
||||
records = items.compactMap(Self.record(from:))
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func markCompleted(id: String) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
Self.fetchManagedObjects(id: id, in: context).forEach(context.delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func markFailed(id: String, error: Error) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
for item in Self.fetchManagedObjects(id: id, in: context) {
|
||||
let currentAttempts = item.value(forKey: "attemptCount") as? Int ?? 0
|
||||
item.setValue(currentAttempts + 1, forKey: "attemptCount")
|
||||
item.setValue(Date(), forKey: "lastAttemptAt")
|
||||
item.setValue(error.localizedDescription, forKey: "lastError")
|
||||
}
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(kind: String, targetID: String) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
Self.fetchManagedObjects(kind: kind, targetID: targetID, in: context).forEach(context.delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
let request = NSFetchRequest<NSFetchRequestResult>(entityName: Schema.entityName)
|
||||
let delete = NSBatchDeleteRequest(fetchRequest: request)
|
||||
_ = try? context.execute(delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetchManagedObjects(id: String, in context: NSManagedObjectContext) -> [NSManagedObject] {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
request.fetchLimit = 1
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private static func fetchManagedObjects(
|
||||
kind: String,
|
||||
targetID: String,
|
||||
in context: NSManagedObjectContext
|
||||
) -> [NSManagedObject] {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.predicate = NSPredicate(format: "kind == %@ AND targetID == %@", kind, targetID)
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private static func saveIfNeeded(_ context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else { return }
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private static func record(from object: NSManagedObject) -> OfflineReplayRecord? {
|
||||
guard
|
||||
let id = object.value(forKey: "id") as? String,
|
||||
let kind = object.value(forKey: "kind") as? String,
|
||||
let operation = object.value(forKey: "operation") as? String,
|
||||
let payload = object.value(forKey: "payload") as? Data,
|
||||
let queuedAt = object.value(forKey: "queuedAt") as? Date
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return OfflineReplayRecord(
|
||||
id: id,
|
||||
kind: kind,
|
||||
operation: operation,
|
||||
targetID: object.value(forKey: "targetID") as? String,
|
||||
payload: payload,
|
||||
queuedAt: queuedAt,
|
||||
attemptCount: object.value(forKey: "attemptCount") as? Int ?? 0,
|
||||
lastAttemptAt: object.value(forKey: "lastAttemptAt") as? Date,
|
||||
lastError: object.value(forKey: "lastError") as? String
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeModel() -> NSManagedObjectModel {
|
||||
let model = NSManagedObjectModel()
|
||||
let entity = NSEntityDescription()
|
||||
entity.name = Schema.entityName
|
||||
entity.managedObjectClassName = NSStringFromClass(NSManagedObject.self)
|
||||
|
||||
entity.properties = [
|
||||
attribute("id", type: .stringAttributeType, optional: false),
|
||||
attribute("kind", type: .stringAttributeType, optional: false),
|
||||
attribute("operation", type: .stringAttributeType, optional: false),
|
||||
attribute("targetID", type: .stringAttributeType, optional: true),
|
||||
attribute("payload", type: .binaryDataAttributeType, optional: false),
|
||||
attribute("queuedAt", type: .dateAttributeType, optional: false),
|
||||
attribute("attemptCount", type: .integer64AttributeType, optional: false, defaultValue: 0),
|
||||
attribute("lastAttemptAt", type: .dateAttributeType, optional: true),
|
||||
attribute("lastError", type: .stringAttributeType, optional: true),
|
||||
]
|
||||
model.entities = [entity]
|
||||
return model
|
||||
}
|
||||
|
||||
private static func attribute(
|
||||
_ name: String,
|
||||
type: NSAttributeType,
|
||||
optional: Bool,
|
||||
defaultValue: Any? = nil
|
||||
) -> NSAttributeDescription {
|
||||
let attribute = NSAttributeDescription()
|
||||
attribute.name = name
|
||||
attribute.attributeType = type
|
||||
attribute.isOptional = optional
|
||||
attribute.defaultValue = defaultValue
|
||||
return attribute
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user