import Foundation import Observation import RastrilloApple import RastrilloNative @MainActor @Observable final class DocsModel { enum ListState { case loading, loaded, failed } var listState = ListState.loading private var listRequest = UUID() var documents: [DocumentSummary] = [] var canCreate = false var errorKey: String? var detail: DocumentDetail? var projection: CRDTProjection? var projectionGeneration: UInt64 = 0 var renderedGeneration: UInt64 = 0 var composing = false var inputPaused = false var explicitCommandRevision: UInt64 = 0 var loading = false var creatingDocument = false private var lifetime = UUID() private(set) var draftToken: String? private var creationTask: Task? private var pendingTitle: String? private var metadataDirty = false var renameInput: String? private var titleRevision: UInt64 = 0 var isDraft: Bool { draftToken != nil } var editorIdentity = UUID() var selection = NSRange(location: 0, length: 0) var typingMarks: [String: JSONValue]? private var replica: CRDTReplica? private var sequence: Int64 = 0 let offlineAccount: DocsOfflineAccount private var persistence: DocsPersistence? private var documentKey: String? private var failedUpdate: Data? private var localFailure: SyncFailure? private var writerTask: Task? private let writer = CoalescedRunner() private var laneSummary = OutboxSummary() private var summaryTask: Task? var syncStatus: SyncStatus { offlineAccount.drainer.status(lane: documentKey.map { "doc:" + $0 }, localFailure: localFailure) } var lostChangesNotice: String? { outboxSummary.lostUnsent ? NativeSyncStrings.kit.unsentLost : nil } func dismissLostChangesNotice() async { await offlineAccount.outbox.acknowledgeLoss() } var outboxSummary: OutboxSummary { offlineAccount.drainer.summary } private var editing: [[String: JSONValue]] = [] private var editingTask: Task? private var streamTask: Task? private var saving = false private var documentEpoch = UUID() private let publications = DocumentPublication() /// Resolved on every request, never captured: this model can hold unsent /// edits for longer than one session, and signing in again replaces the /// transport under it. A captured client would keep sending with the ended /// session. nil means no session; requests then fail as `.unauthorized`. private let transport: @MainActor () -> NativeTransport? /// The stream loop waits on this between attempts, so a restored network /// reconnects at once and an offline device makes no timed attempts. let connectivity: NativeConnectivity /// Monotonic seconds for the stream loop's pacing; tests substitute a fake. var reconnectClock: @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } init(transport: @escaping @MainActor () -> NativeTransport?, connectivity: NativeConnectivity? = nil, draft: Bool = false, offlineAccount: DocsOfflineAccount? = nil) { self.transport = transport self.connectivity = connectivity ?? .shared self.offlineAccount = offlineAccount ?? DocsOfflineAccount(store: EphemeralDocsStore(), accountID: UUID().uuidString, connectivity: connectivity ?? .shared, transport: transport) if draft { draftToken = UUID().uuidString } } convenience init(client: NativeTransport, connectivity: NativeConnectivity? = nil, draft: Bool = false) { self.init(transport: { client }, connectivity: connectivity, draft: draft) } private func client() throws -> NativeTransport { guard let current = transport() else { throw NativeClientError.unauthorized } return current } var hasPendingInput: Bool { !editing.isEmpty || editingTask != nil } var hasUnpersistedInput: Bool { metadataDirty || (renameInput != nil && renameInput != detail?.title) || composing || !editing.isEmpty || editingTask != nil || failedUpdate != nil || localFailure == .cannotSaveOnDevice } var hasUnsentChanges: Bool { !laneSummary.isEmpty || pendingTitle != nil || isDraft } // K6 relaxes the close guard; K3 exposes the split without bypassing it. var hasUnsavedChanges: Bool { hasUnpersistedInput || hasUnsentChanges } private func configurePersistence(key: String, replica: CRDTReplica) { documentKey = key persistence = DocsPersistence(store: offlineAccount.store, outbox: offlineAccount.outbox, key: key, replica: replica, cache: offlineAccount.replicaCache) summaryTask?.cancel() summaryTask = Task { [weak self, outbox = offlineAccount.outbox] in for await summary in await outbox.changes { guard let self, self.documentKey == key else { return } self.updateLaneSummary(summary.only("doc:" + key)) self.reloadMetadata() } } } private func updateLaneSummary(_ summary: OutboxSummary) { laneSummary = summary let tooLarge = summary.lanes.values.contains { $0.bytes > 32 * 1024 * 1024 } if tooLarge, localFailure == nil || localFailure == .tooManyUnsent { localFailure = .tooManyUnsent; inputPaused = true } else if !tooLarge, localFailure == .tooManyUnsent { localFailure = nil; inputPaused = detail?.can_write != true || summary.held > 0 } } private func reloadMetadata() { guard !metadataDirty, let key = documentKey, let stored = try? offlineAccount.metadata(key) else { return } let attached = draftToken != nil && stored.draftToken == nil detail = stored.detail; pendingTitle = stored.pendingTitle; draftToken = stored.draftToken if attached, streamTask == nil { streamTask = Task { [weak self] in await self?.listen(stored.detail.sqid) } } } private func saveMetadata() throws { guard let documentKey, let detail else { return } metadataDirty = true try offlineAccount.saveMetadata(DocsOfflineMetadata(detail: detail, pendingTitle: pendingTitle, draftToken: draftToken), key: documentKey) metadataDirty = false } private func schedulePersistence() { writerTask?.cancel() writerTask = Task { [weak self] in do { try await Task.sleep(for: .seconds(2)) } catch { return } await self?.persistReplica() } } func persistReplica() async { await writer.run { [weak self] in guard let self, let persistence = self.persistence else { return } do { if try await !persistence.persist() { self.schedulePersistence() } else if self.localFailure == .cannotSaveOnDevice, self.failedUpdate == nil, !self.metadataDirty { self.localFailure = nil if self.detail?.can_write == true { self.inputPaused = false } } } catch { self.localFailure = .cannotSaveOnDevice; self.inputPaused = true } } } private func durable(_ update: Data) async throws { guard let persistence else { throw BlobStoreError.unavailable } failedUpdate = update try await persistence.enqueue(update) failedUpdate = nil updateLaneSummary(await offlineAccount.outbox.summary.only(persistence.lane)) try await persistence.compact() } func load(query: String = "") async { do { try await offlineAccount.activate() } catch { localFailure = .cannotSaveOnDevice } let request = UUID(); listRequest = request listState = .loading do { var components = URLComponents(); components.queryItems = [URLQueryItem(name: "q", value: query)] let q = (components.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B") let response: DocumentList = try await client().request("/?\(q)") guard listRequest == request, !Task.isCancelled else { return } documents = response.documents; canCreate = response.can_create listState = .loaded; errorKey = nil } catch { guard listRequest == request else { return } listState = .failed; errorKey = "docs.error.generic" } } /// The editor and its initial paragraph are local. Server acknowledgement /// only attaches an identity; it never reconstructs the editor or selection. func newDraft() async { guard detail == nil, replica == nil else { return } if draftToken == nil { draftToken = UUID().uuidString } let epoch = documentEpoch let guid = UUID().uuidString let local = CRDTReplica(guid: guid) loading = true defer { if documentEpoch == epoch { loading = false } } do { try await offlineAccount.outbox.load() _ = try await local.open(update: Data()) let (update, _) = try await local.command(["op": .string("insert_element"), "at": .number(0), "type": .string("paragraph")]) guard documentEpoch == epoch else { await local.close(); return } replica = local configurePersistence(key: "draft-" + draftToken!, replica: local) detail = DocumentDetail(sqid: "draft-" + draftToken!, title: String(localized: "docs.document.untitled", bundle: DocsResources.bundle), guid: guid, can_write: true, can_share: false) failedUpdate = update try saveMetadata() try await durable(update) await publish() guard documentEpoch == epoch else { return } offlineAccount.drainer.start() startCreation() } catch { if documentEpoch == epoch { errorKey = "native.pending"; localFailure = .cannotSaveOnDevice; inputPaused = true } } } private func startCreation() { guard creationTask == nil, draftToken != nil else { return } creatingDocument = true let epoch = documentEpoch creationTask = Task { await attachDraft(epoch: epoch) if documentEpoch == epoch { creationTask = nil; creatingDocument = false } } } private func attachDraft(epoch: UUID) async { guard let key = documentKey else { return } do { try await offlineAccount.ensureMetadataEntry(key) await offlineAccount.drainer.drain() guard documentEpoch == epoch else { return } reloadMetadata() if isDraft { errorKey = "native.pending" } else { await persistReplica(); await flush() } } catch { if documentEpoch == epoch { errorKey = "native.pending" } } } @discardableResult func commitRename() -> Bool { guard let value = renameInput else { return true } guard rename(value) else { return false } renameInput = nil return true } @discardableResult func rename(_ value: String) -> Bool { guard let detail, detail.can_write else { return false } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let title = trimmed.isEmpty ? String(localized: "docs.document.untitled", bundle: DocsResources.bundle) : trimmed guard title.utf8.count <= 1024 else { errorKey = "docs.error.generic"; return false } if title == detail.title { guard metadataDirty else { return true } do { try saveMetadata() } catch { localFailure = .cannotSaveOnDevice; inputPaused = true; return false } } self.detail = DocumentDetail(sqid: detail.sqid, title: title, guid: detail.guid, can_write: detail.can_write, can_share: detail.can_share) pendingTitle = title; titleRevision &+= 1 do { try saveMetadata() } catch { localFailure = .cannotSaveOnDevice; inputPaused = true; return false } Task { do { if let documentKey { try await offlineAccount.ensureMetadataEntry(documentKey) } } catch { localFailure = .cannotSaveOnDevice; inputPaused = true } await flush() } return true } func open(_ sqid: String) async { guard !hasUnsavedChanges else { errorKey = "native.pending"; return } let epoch = UUID(); documentEpoch = epoch; editorIdentity = UUID() streamTask?.cancel(); streamTask = nil let previous = replica; replica = nil; detail = nil; projection = nil loading = true; errorKey = nil if let previous { await previous.close() } guard documentEpoch == epoch else { return } defer { if documentEpoch == epoch { loading = false } } do { try await offlineAccount.outbox.load() let key = try offlineAccount.key(for: sqid) let cached = try offlineAccount.metadata(key) pendingTitle = cached?.pendingTitle; draftToken = cached?.draftToken let detail: DocumentDetail let initial: Data let seq: Int64 if let cached, try offlineAccount.store.read("cache/docs/\(key)/replica") != nil { detail = cached.detail; pendingTitle = cached.pendingTitle; initial = Data(); seq = 0 } else { let remote: DocumentDetail = try await client().request("/d/\(sqid)") detail = DocumentDetail(sqid: remote.sqid, title: cached?.pendingTitle ?? remote.title, guid: remote.guid, can_write: remote.can_write, can_share: remote.can_share) let reply = try await client().responseDetails("/d/\(sqid)/updates", maxBytes: 8 * 1024 * 1024) try reply.requireSuccess() guard let cursor = Int64(reply.header("Docs-Seq") ?? "") else { throw CRDTReplica.Failure.rejected } initial = reply.data; seq = cursor } guard documentEpoch == epoch else { return } let replica = CRDTReplica(guid: detail.guid) configurePersistence(key: key, replica: replica) try await persistence!.reopen(fallback: initial, sequence: seq) let projection = try await replica.projection() guard documentEpoch == epoch else { await replica.close(); return } self.replica = replica; self.detail = detail reloadMetadata() sequence = try await replica.persistentState().sequence laneSummary = await offlineAccount.outbox.summary.only("doc:" + key) if cached == nil { try saveMetadata() } try await offlineAccount.ensureMetadataEntry(key) projectionGeneration = 0; renderedGeneration = 0 selection = NSRange(location: 0, length: 0); typingMarks = nil composing = false; inputPaused = false updateLaneSummary(laneSummary) await publish() guard documentEpoch == epoch else { return } if projection.children.isEmpty && detail.can_write { enqueue(["op":.string("insert_element"), "at":.number(0), "type":.string("paragraph")]) } await persistReplica() offlineAccount.drainer.start() streamTask = Task { [weak self] in await self?.listen(sqid) } } catch { if documentEpoch == epoch { errorKey = "docs.error.generic"; detail = nil; projection = nil } } } @discardableResult func enqueue(_ command: [String: JSONValue]) -> Bool { guard !inputPaused, detail?.can_write == true else { return false } let buffered = editing.reduce(0) { $0 + ((try? JSONEncoder().encode($1).count) ?? 0) } guard let incoming = try? JSONEncoder().encode(command).count, incoming <= 2 * 1024 * 1024 - buffered else { errorKey = "native.pending"; return false } var command = command if case .number(let generation) = command["generation"], let value = UInt64(exactly: generation) { didRender(value) } if command["generation"] == nil, renderedGeneration != 0 { command["generation"] = .number(Double(renderedGeneration)) } if [JSONValue.string("replace_text"), .string("display_edit")].contains(command["op"] ?? .null), let typingMarks { command["marks"] = .object(typingMarks) } if ![JSONValue.string("display_edit"), .string("display_autoformat"), .string("display_revert_rule"), .string("display_exit_list"), .string("insert_element")].contains(command["op"] ?? .null) { explicitCommandRevision &+= 1 } editing.append(command) if editingTask == nil { editingTask = Task { await drainEdits() } } return true } private func drainEdits() async { let epoch = documentEpoch defer { if documentEpoch == epoch { editingTask = nil } } while documentEpoch == epoch, !editing.isEmpty, let replica { let command = editing.removeFirst() do { let (update, _) = try await replica.command(command) guard documentEpoch == epoch else { return } do { try await durable(update) } catch { localFailure = .cannotSaveOnDevice; inputPaused = true; break } if editing.isEmpty { await publish() } } catch { guard documentEpoch == epoch else { return } errorKey = "native.recovered"; editing.insert(command, at: 0); inputPaused = true; break } } Task { await flush() } } func compositionChanged(_ active: Bool) { composing = active if !active && editingTask == nil { Task { await publish() } } } func canRender(_ generation: UInt64) -> Bool { !hasPendingInput && !composing && generation == projectionGeneration } func currentSelection() -> NSRange { selection } func selectionMovedExplicitly() { typingMarks = nil } func didRender(_ generation: UInt64) { // A delayed acknowledgement cannot retire the currently displayed source. guard generation > renderedGeneration else { return } renderedGeneration = generation } private func publish() async { await publications.run { await self.publishCurrent() } } private func publishCurrent() async { let epoch = documentEpoch guard !composing, editing.isEmpty, let replica else { return } await replica.retainGenerations(Set([renderedGeneration, projectionGeneration])) guard documentEpoch == epoch, editing.isEmpty else { return } do { let source = renderedGeneration; let selected = selection if let (generation, projected, resolved) = try await replica.presentation(source: source, selection: selected, allowReuse: source == projectionGeneration), editing.isEmpty, source == renderedGeneration, selected == selection, !composing, documentEpoch == epoch, generation >= projectionGeneration { selection = resolved; projectionGeneration = generation; projection = projected schedulePersistence() } } catch { if documentEpoch == epoch { errorKey = "native.recovered" } } } func retry() async { let epoch = documentEpoch if localFailure == .cannotSaveOnDevice || failedUpdate != nil { do { try await offlineAccount.outbox.load() if metadataDirty { try saveMetadata() } if let failedUpdate { try await durable(failedUpdate) } guard documentEpoch == epoch else { return } localFailure = nil; inputPaused = false await persistReplica() guard localFailure == nil else { return } await publish() } catch { return } } if let persistence { updateLaneSummary(await offlineAccount.outbox.summary.only(persistence.lane)) } if localFailure == .tooManyUnsent { return } offlineAccount.drainer.start() if editingTask == nil, !editing.isEmpty, !inputPaused { editingTask = Task { await drainEdits() } } if isDraft { if replica == nil { await newDraft() } else { startCreation() } await creationTask?.value return } if transport() != nil { offlineAccount.drainer.sessionRestored() } offlineAccount.drainer.retryNow() if inputPaused { if let detail { await refreshAccess(detail.sqid, epoch: epoch) } guard documentEpoch == epoch, detail?.can_write == true else { return } inputPaused = false; errorKey = nil if editingTask == nil { editingTask = Task { await drainEdits() } } } else { await flush() } } func stop() async { lifetime = UUID() await persistReplica() guard !hasUnsavedChanges else { return } documentEpoch = UUID(); streamTask?.cancel(); streamTask = nil writerTask?.cancel(); summaryTask?.cancel() let previous = replica; replica = nil; detail = nil; projection = nil if let previous { await previous.close() } } /// Discard only unsent local draft state. An already acknowledged network /// update remains part of the shared document, as in the web editor. func discardAll() async { documentEpoch = UUID() writerTask?.cancel(); editingTask?.cancel(); streamTask?.cancel() do { try await persistence?.discard() if let documentKey { try offlineAccount.store.remove("cache/docs/\(documentKey)/meta") } } catch { localFailure = .cannotSaveOnDevice; return } writerTask?.cancel(); summaryTask?.cancel(); persistence = nil; documentKey = nil laneSummary = OutboxSummary(); failedUpdate = nil; localFailure = nil documentEpoch = UUID() creationTask?.cancel(); creationTask = nil; creatingDocument = false draftToken = nil; pendingTitle = nil; metadataDirty = false; renameInput = nil streamTask?.cancel(); streamTask = nil editingTask?.cancel(); editingTask = nil editing.removeAll(); saving = false composing = false; inputPaused = false; typingMarks = nil selection = NSRange(location: 0, length: 0) let previous = replica; replica = nil; detail = nil; projection = nil projectionGeneration = 0; renderedGeneration = 0; errorKey = nil if let previous { await previous.close() } } func saveAll() async -> Bool { guard commitRename() else { return false } if isDraft { startCreation(); await creationTask?.value } if let editingTask { await editingTask.value } while saving { try? await Task.sleep(for: .milliseconds(20)) } await flush() await persistReplica() return !hasUnsavedChanges } func flush() async { guard !saving, detail != nil, let persistence else { return }; saving = true let epoch = documentEpoch defer { if documentEpoch == epoch { saving = false } } do { try await offlineAccount.ensureMetadataEntry(persistence.key) } catch { localFailure = .cannotSaveOnDevice; inputPaused = true; return } await offlineAccount.drainer.drain() guard documentEpoch == epoch else { return } laneSummary = await offlineAccount.outbox.summary.only(persistence.lane) reloadMetadata() // SyncStatus presents a durable pending lane; it does not require // keeping this document open. Preserve unrelated actionable errors. if errorKey == "native.pending" || errorKey == "docs.editor.status.retrying" { errorKey = nil } } private func listen(_ sqid: String) async { let epoch = documentEpoch // Paces reconnects: a server's Retry-After is a deadline no wake // crosses; otherwise back off, cut short by a connectivity change, // with no timer while the path is down. The backoff resets only after // a stream that stayed open for 5 s, timed from onOpen. let reconnect = NativeReconnect(connectivity: connectivity, now: reconnectClock) while !Task.isCancelled, documentEpoch == epoch, detail?.sqid == sqid { await flush() do { try await client().stream("/d/\(sqid)/stream", onOpen: { await reconnect.opened() }) { [weak self] line in if line.hasPrefix("data:"), let seq = Int64(line.dropFirst(5).trimmingCharacters(in: .whitespaces)) { await self?.refresh(sqid, remoteSequence: seq) } } } catch { reconnect.failed(error) if !Task.isCancelled, documentEpoch == epoch { errorKey = "docs.editor.status.retrying" await refreshAccess(sqid, epoch: epoch) } } await reconnect.pause() } } private func refresh(_ sqid: String, remoteSequence: Int64) async { let epoch = documentEpoch guard detail?.sqid == sqid, remoteSequence > sequence, let replica else { return } do { let reply = try await client().responseDetails("/d/\(sqid)/updates?since=\(sequence)", maxBytes: 8 * 1024 * 1024) guard reply.status == 200, let seq = Int64(reply.header("Docs-Seq") ?? "") else { throw CRDTReplica.Failure.rejected } guard self.detail?.sqid == sqid, self.replica === replica else { return } _ = try await replica.apply(reply.data) guard self.detail?.sqid == sqid, self.replica === replica else { return } sequence = max(sequence, seq) await replica.setSequence(sequence) await persistReplica() if editing.isEmpty && editingTask == nil { await publish() } await flush() } catch { if documentEpoch == epoch { errorKey = "docs.editor.status.retrying" } } } /// Revalidate capability after stream/write rejection while retaining the draft. private func refreshAccess(_ sqid: String, epoch: UUID) async { let revision = titleRevision let hadPendingTitle = pendingTitle != nil do { let reply = try await client().responseDetails("/d/\(sqid)", maxBytes: 1024 * 1024) guard documentEpoch == epoch else { return } if reply.status == 200 { let current = try JSONDecoder().decode(DocumentDetail.self, from: reply.data) // The GET may have read its title before a rename acknowledged. // Clearing pendingTitle must not let that old reply win afterward. let title = pendingTitle ?? ((hadPendingTitle || titleRevision != revision) ? (detail?.title ?? current.title) : current.title) detail = DocumentDetail(sqid: current.sqid, title: title, guid: current.guid, can_write: current.can_write, can_share: current.can_share) if !current.can_write { inputPaused = true } } else if [401, 403, 404].contains(reply.status) { inputPaused = true if let current = detail { detail = DocumentDetail(sqid: current.sqid, title: current.title, guid: current.guid, can_write: false, can_share: false) } } } catch { /* A connection failure cannot establish a capability change. */ } } }