Add sandboxed native Docs CRDT foundation and Apple editor shell
33 files changed,
+2060
−0
docs/superpowers/plans/2026-09-13-native-apple.md+9 −0docs/superpowers/specs/2026-09-13-native-apple-design.md+35 −0native/.gitignore+7 −0native/REVIEW.md+5 −0native/apple/Package.swift+10 −0native/apple/Sources/DocsApp/DocsApp.swift+18 −0native/apple/Sources/DocsKit/CRDTProjection.swift+42 −0native/apple/Sources/DocsKit/CRDTReplica.swift+68 −0native/apple/Sources/DocsKit/DocsModel.swift+113 −0native/apple/Sources/DocsKit/DocsModels.swift+16 −0native/apple/Sources/DocsKit/DocsWorkspace.swift+54 −0native/apple/Sources/DocsKit/EditorToolbar.swift+60 −0native/apple/Sources/DocsKit/NativeDocumentTextView.swift+80 −0native/apple/Sources/DocsKit/NativeTextProjection.swift+83 −0native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings+159 −0native/apple/Sources/DocsKit/TextEditMapping.swift+22 −0native/crdt/Cargo.lock+347 −0native/crdt/Cargo.toml+22 −0native/crdt/src/lib.rs+137 −0native/crdt/src/operations.rs+95 −0native/crdt/src/projection.rs+38 −0native/crdt/src/structure.rs+77 −0native/crdt/tests/replica.rs+64 −0native/host/Cargo.lock+310 −0native/host/Cargo.toml+10 −0native/host/include/DocsCRDT.h+8 −0native/host/include/module.modulemap+4 −0native/host/src/lib.rs+80 −0native/host/tests/sandbox.rs+23 −0native/ios/project.yml+33 −0native/scripts/build-crdt.sh+17 −0native/scripts/build-iphone.sh+10 −0native/scripts/build-mac.sh+4 −0
diff --git a/docs/superpowers/plans/2026-09-13-native-apple.md b/docs/superpowers/plans/2026-09-13-native-apple.md| new file mode 100644 |
| index 0000000..00e7f3d |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-13-native-apple.md |
| @@ -0,0 +1,9 @@ |
| +# Native Apple implementation plan |
| + |
| +Spec: ../specs/2026-09-13-native-apple-design.md. Independent spec review approved by agent dispatched as gpt-6-astra (high), 2026-09-13; runtime identity not independently verified. Spec approval is not branch or parity acceptance. |
| + |
| +1. Add CRDT native mutation ABI to a separately built WASM module, maintaining the existing server ABI and artifact. Add native interpreted host wrapper with bounded calls. Verify identity-preserving Yjs round trips and UTF-16 mutations before wiring a UI. |
| +2. Add shared proof-bound native authentication and metadata/sharing JSON endpoints using current session/admission/owner checks. Preserve store encryption and transport routes. |
| +3. Implement Apple document projection, selection/IME-aware native editor, command catalog and clipboard/layout adapters. Bind command mutations to CRDT deltas and own-origin undo. |
| +4. Implement native list, sharing, exports and print; use generated twelve-locale resources. Integrate shared bounded transport and SSE, retaining pending edits and surfacing failures. |
| +5. Build Mac, iPhone/iPad simulator and device apps; test fixtures/concurrent browser editing, run web gates and independent native/branch review. Record any unmet acceptance explicitly. |
diff --git a/docs/superpowers/specs/2026-09-13-native-apple-design.md b/docs/superpowers/specs/2026-09-13-native-apple-design.md| new file mode 100644 |
| index 0000000..ad7b3e3 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-13-native-apple-design.md |
| @@ -0,0 +1,35 @@ |
| +# Docs native Apple clients |
| + |
| +Paul's decision, 2026-09-13: build macOS and iOS simultaneously, using entirely native editing controls, with parity against the implemented web app. Reuse the pinned RastrilloApple native sign-in infrastructure from Calendar and Memoria. |
| + |
| +## Actual parity inventory |
| + |
| +Inventory source is this native/apple-clients worktree's inherited web implementation: fixtures/editor-entry.js, toolbar.mjs, menu.mjs, autoformat.mjs, pages.mjs, wordcount.mjs; internal/docs/menu.go, handlers.go, share.go, publish.go, app.go. |
| + |
| +- Document listing, recent groups, title/content search, opening owned/shared documents, creating documents for members; guests retain their current restricted access. |
| +- Native rich text editing: paragraphs, headings 1–3, bold, italic, inline code, sans/serif/mono fonts, the eleven explicit size values and three persisted legacy size marks, left/center/right paragraph alignment, indent/outdent, bullet/ordered lists including nesting, block quotes, hard breaks, selection, clipboard editing, undo/redo, clear formatting, existing keyboard shortcuts and Markdown-like input rules. |
| +- Preserve the entire existing ProseMirror Y.XmlFragment model, including schema nodes/attributes/marks that can already arrive through paste or existing documents (links, image nodes, horizontal rules, code blocks and headings 4–6). These known schema nodes must render and remain editable to the extent the web permits; merely retaining opaque data does not meet parity. Truly unknown future schema content must additionally be retained losslessly; it must never be flattened or rewritten. |
| +- Automatic collaborative updates with current permissions, bounded retries, conflict/rebase behavior, save failures clearly visible, no discard on closing an unsaved editor. Own undo must not undo another collaborator's work. |
| +- Paged/infinite personal presentation preference, document/selection word counts, keyboard shortcut reference, PDF/Markdown export and native printing. |
| +- Native owner sharing management: grant/revoke existing roles and public link enable/disable, using the same admission and ownership rules as web. |
| +- No claim for the web's disabled placeholders: find/replace, ruler/tab stops, insert link/image/table/page break/rule UI. Comments and version-history editors have no implemented routes and are outside current parity. |
| + |
| +## Implementation and invariants |
| + |
| +Add native/ Swift package/Xcode project and reuse shared auth/build packaging at revision 33ffe994173438bbb45e88d16061eba2ac4f6d4c. SwiftUI owns navigation, commands, settings and sharing. Platform rich-text views (NSTextView/UITextView) are allowed where necessary for selection-aware editing, paragraph layout and native clipboard; no WKWebView, JavaScript editor engine or HTML editing surface. |
| + |
| +Keep the existing pinned yrs engine inside a WebAssembly sandbox, interpreted on Apple through a narrow native C ABI wrapper using the already-pinned wasmi runtime. The web editor is never embedded; WebAssembly contains only the CRDT engine. Directly decoding untrusted updates in statically linked yrs is prohibited: core/src/abi.rs records unchecked UTF-8 decode operations that current server isolation deliberately contains. Bound sandbox memory and fuel; malformed data or traps produce an actionable error and no partial accepted mutation. A native replica owns client identity, fragment and UndoManager. Native commands mutate identifiable XML nodes/ranges transactionally and emit Yjs v1 deltas. Never rebuild the full fragment from an attributed-string snapshot. Projection includes stable CRDT node IDs and UTF-16 ranges so selected edits target existing content and remote updates preserve unaffected identities. Stable selection anchors and IME composition buffering are required. Native random identity comes from the OS, not a deterministic server identifier. The host supplies GUID and client ID to sandbox creation; undo tracking uses an explicit local transaction origin, excluding imported peer updates. Rust panic/error boundaries must never unwind through Swift. Each document owns an isolated interpreter instance running off the main actor. Fuel and memory limits apply to every call including allocation, projection, snapshots and mutations. Validate host ABI lengths, UTF-8 and decoded projection shape before use. On a trap discard the instance and restore the last-good replica plus retained unacknowledged updates; create a fresh OS-random client ID before issuing new mutations so rolled-back clocks cannot be reused. If undo history cannot be recovered, clear it with a localized visible explanation. Set yrs Options.offset_kind to UTF-16 explicitly; Apple text storage offsets are UTF-16, and surrogate pairs must never be split. |
| + |
| +Keep shared server auth separate from existing browser cookies, using the proof-bound native approval/exchange implementation with current admission checks. Native JSON routes expose metadata and sharing; CRDT transport reuses the existing store validation/snapshot/updates code paths, owner/role checks, byte limits and encrypted storage. Responses are no-store and unauthorized access is nondisclosing. Native tokens must be revocable and origin-bound; no transport response is trusted just because it has JSON. |
| + |
| +Before export/sharing or closing, await acknowledged pending updates or present a visible actionable failure. Retry queues are bounded and retain unacknowledged edits; reconnection fetches server state and merges CRDT deltas rather than replacing content. A revoked reader/editor must not retain mutation capability through an established stream. Recheck token expiry/revocation, session validity and current admission before each native stream delivery, as well as the existing document permission/lease check. |
| + |
| +Document replicas and pending queues remain in memory only, with ephemeral URLSession configuration and no URLCache disk storage; neither content nor tokens enter logs or error strings. Any future background persistence requires authenticated encryption and a separate reviewed design. User-initiated clipboard and export destinations are explicit exceptions, not permission for background caches or autosave. |
| + |
| +All user-visible strings resolve through a native String Catalog generated from the existing twelve base locale catalogs where matching translations exist; new keys receive all twelve translations. Native controls carry localized accessible names, Dynamic Type support, keyboard navigation, visible non-color state, minimum targets and small-screen reflow. Verify VoiceOver and keyboard behavior, not merely label presence. |
| + |
| +## Acceptance |
| + |
| +Native edit fixtures round-trip through Yjs and yrs in both directions with formatting, Unicode, nested lists and unknown/schema-preserved content. Concurrent native/browser edits and own-only undo preserve peer edits. Non-BMP insertion/deletion, grapheme selection and malformed UTF-8 cases must cover the explicit UTF-16 boundary. Trap/fuel recovery tests prove restored unacknowledged edits and non-reused clocks. Tests cover reconnect, save failure, reader/guest/owner separation, revoked grants, malformed/oversized data, IME selection and clipboard behavior. Each new test must be observed failing for the intended cause as required by AGENTS.md. |
| + |
| +Build and test Mac plus iOS simulator/device architectures. Exercise native app creation, formatting, nesting, export and sharing with isolated fixtures. Run existing make ci and native tests. Record visual/accessibility verification and any missing parity candidly; successful compilation alone is not parity. No deployment or publication is part of this task. |
diff --git a/native/.gitignore b/native/.gitignore| new file mode 100644 |
| index 0000000..a792582 |
| --- /dev/null |
| +++ b/native/.gitignore |
| @@ -0,0 +1,7 @@ |
| +**/target/ |
| +**/.build/ |
| +**/.swiftpm/ |
| +build/ |
| +*.xcodeproj/ |
| + |
| +apple/Artifacts/ |
diff --git a/native/REVIEW.md b/native/REVIEW.md| new file mode 100644 |
| index 0000000..38784c6 |
| --- /dev/null |
| +++ b/native/REVIEW.md |
| @@ -0,0 +1,5 @@ |
| +# Native Docs review record |
| + |
| +2026-09-13: independent specification review dispatched as gpt-6-astra with high reasoning. Actual runtime identity was not independently verified. The amended specification was approved after adding native known-schema editing parity, twelve locales/accessibility, sandboxed CRDT decoding, bounded per-document recovery, explicit UTF-16 positions, memory-only content and current stream authorization. |
| + |
| +This is specification approval only. Implementation, branch review and full parity acceptance are pending. |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| new file mode 100644 |
| index 0000000..1b139a9 |
| --- /dev/null |
| +++ b/native/apple/Package.swift |
| @@ -0,0 +1,10 @@ |
| +// swift-tools-version: 6.2 |
| +import PackageDescription |
| +let package = Package(name: "DocsNative", defaultLocalization: "en", platforms: [.macOS(.v26), .iOS(.v26)], |
| + products: [.library(name: "DocsKit", targets: ["DocsKit"]), .executable(name: "DocsMac", targets: ["DocsMac"])], |
| + dependencies: [.package(path: "../../../rastrillo-native-editors")], |
| + targets: [ |
| + .binaryTarget(name: "DocsCRDT", path: "Artifacts/DocsCRDT.xcframework"), |
| + .target(name: "DocsKit", dependencies: ["DocsCRDT", .product(name: "RastrilloApple", package: "rastrillo-native-editors")], resources: [.process("Resources")]), |
| + .executableTarget(name: "DocsMac", dependencies: ["DocsKit", .product(name: "RastrilloApple", package: "rastrillo-native-editors")], path: "Sources/DocsApp"), |
| + ]) |
diff --git a/native/apple/Sources/DocsApp/DocsApp.swift b/native/apple/Sources/DocsApp/DocsApp.swift| new file mode 100644 |
| index 0000000..64b6441 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsApp/DocsApp.swift |
| @@ -0,0 +1,18 @@ |
| +import DocsKit |
| +import RastrilloApple |
| +import SwiftUI |
| +@main struct DocsApp: App { |
| + @StateObject private var connection = NativeConnection(defaultServer: "https://docs.oficina.works") |
| + var body: some Scene { |
| + WindowGroup { |
| + if let client = connection.client { |
| + DocsWorkspace(client: client, signOut: connection.signOut) |
| + } else { |
| + NativeSignInView("Oficina Docs", systemImage: "doc.text", connection: connection) |
| + } |
| + } |
| + #if os(macOS) |
| + .defaultSize(width: 1120, height: 820) |
| + #endif |
| + } |
| +} |
diff --git a/native/apple/Sources/DocsKit/CRDTProjection.swift b/native/apple/Sources/DocsKit/CRDTProjection.swift| new file mode 100644 |
| index 0000000..26ce3c5 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/CRDTProjection.swift |
| @@ -0,0 +1,42 @@ |
| +import Foundation |
| + |
| +struct CRDTProjection: Decodable, Sendable { |
| + var children: [CRDTNode] |
| + var canUndo: Bool |
| + var canRedo: Bool |
| +} |
| +struct CRDTNode: Decodable, Identifiable, Sendable { |
| + let id: String |
| + let type: String |
| + var attrs: [String: String]? |
| + var children: [CRDTNode]? |
| + var runs: [CRDTRun]? |
| + var plainText: String { runs?.map(\.insert).joined() ?? children?.map(\.plainText).joined() ?? "" } |
| +} |
| +struct CRDTRun: Decodable, Sendable { |
| + var insert: String |
| + var marks: [String: JSONValue]? |
| +} |
| +indirect enum JSONValue: Codable, Sendable, Equatable { |
| + case object([String: JSONValue]), array([JSONValue]), string(String), number(Double), bool(Bool), null |
| + init(from decoder: any Decoder) throws { |
| + let c = try decoder.singleValueContainer() |
| + if c.decodeNil() { self = .null } |
| + else if let v = try? c.decode(Bool.self) { self = .bool(v) } |
| + else if let v = try? c.decode(String.self) { self = .string(v) } |
| + else if let v = try? c.decode(Double.self) { self = .number(v) } |
| + else if let v = try? c.decode([String: JSONValue].self) { self = .object(v) } |
| + else { self = .array(try c.decode([JSONValue].self)) } |
| + } |
| + func encode(to encoder: any Encoder) throws { |
| + var c = encoder.singleValueContainer() |
| + switch self { |
| + case .object(let v): try c.encode(v) |
| + case .array(let v): try c.encode(v) |
| + case .string(let v): try c.encode(v) |
| + case .number(let v): try c.encode(v) |
| + case .bool(let v): try c.encode(v) |
| + case .null: try c.encodeNil() |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/DocsKit/CRDTReplica.swift b/native/apple/Sources/DocsKit/CRDTReplica.swift| new file mode 100644 |
| index 0000000..1b77895 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/CRDTReplica.swift |
| @@ -0,0 +1,68 @@ |
| +import DocsCRDT |
| +import Foundation |
| + |
| +/// Actor isolation keeps interpretation away from the main actor and serializes |
| +/// the C handle. Only the WASM interpreter receives untrusted update bytes. |
| +actor CRDTReplica { |
| + private var host: UnsafeMutableRawPointer? |
| + private var lastGood = Data() |
| + private let guid: String |
| + private let maximumBytes = 8 * 1024 * 1024 + 1 |
| + enum Failure: Error { case unavailable, rejected, invalidProjection } |
| + init(guid: String) { self.guid = guid } |
| + func close() { if let host { docs_host_free(host) }; host = nil; lastGood = Data() } |
| + private func invoke(_ data: Data) throws -> Data { |
| + guard let host, !data.isEmpty, data.count <= maximumBytes else { throw Failure.unavailable } |
| + var output = Data(count: maximumBytes) |
| + let count = output.withUnsafeMutableBytes { target in |
| + data.withUnsafeBytes { source in |
| + docs_host_call(host, source.bindMemory(to: UInt8.self).baseAddress!, data.count, |
| + target.bindMemory(to: UInt8.self).baseAddress!, maximumBytes) |
| + } |
| + } |
| + guard count >= 0, count <= maximumBytes else { throw Failure.rejected } |
| + output.count = Int(count) |
| + return output |
| + } |
| + private func invoke(_ command: [String: JSONValue]) throws -> Data { |
| + try invoke(JSONEncoder().encode(command)) |
| + } |
| + func open(update: Data) throws -> CRDTProjection { |
| + if let host { docs_host_free(host) } |
| + host = docs_host_new() |
| + _ = try invoke(["op": .string("open"), "guid": .string(guid), "client": .number(Double(UInt32.random(in: 1...UInt32.max)))]) |
| + if !update.isEmpty { _ = try invoke(Data([255]) + update) } |
| + lastGood = try invoke(["op": .string("snapshot")]) |
| + return try projection() |
| + } |
| + func projection() throws -> CRDTProjection { |
| + let data = try invoke(["op": .string("project")]) |
| + let result = try JSONDecoder().decode(CRDTProjection.self, from: data) |
| + guard result.children.count <= 100_000 else { throw Failure.invalidProjection } |
| + return result |
| + } |
| + func apply(_ update: Data) throws -> CRDTProjection { |
| + do { |
| + _ = try invoke(Data([255]) + update) |
| + let projected = try projection() |
| + lastGood = try invoke(["op": .string("snapshot")]) |
| + return projected |
| + } catch { |
| + let saved = lastGood |
| + _ = try? open(update: saved) |
| + throw error |
| + } |
| + } |
| + func command(_ command: [String: JSONValue]) throws -> (Data, CRDTProjection) { |
| + do { |
| + let update = try invoke(command) |
| + let projected = try projection() |
| + lastGood = try invoke(["op": .string("snapshot")]) |
| + return (update, projected) |
| + } catch { |
| + let saved = lastGood |
| + _ = try? open(update: saved) |
| + throw error |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/DocsKit/DocsModel.swift b/native/apple/Sources/DocsKit/DocsModel.swift| new file mode 100644 |
| index 0000000..769b102 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocsModel.swift |
| @@ -0,0 +1,113 @@ |
| +import Foundation |
| +import Observation |
| +import RastrilloApple |
| + |
| +@MainActor @Observable final class DocsModel { |
| + var documents: [DocumentSummary] = [] |
| + var canCreate = false |
| + var errorKey: String? |
| + var detail: DocumentDetail? |
| + var projection: CRDTProjection? |
| + var loading = false |
| + var selection = NSRange(location: 0, length: 0) |
| + var typingMarks: [String: JSONValue]? |
| + private var replica: CRDTReplica? |
| + private var sequence: Int64 = 0 |
| + private var pending: [Data] = [] |
| + private var editing: [[String: JSONValue]] = [] |
| + private var editingTask: Task<Void, Never>? |
| + private var streamTask: Task<Void, Never>? |
| + private var saving = false |
| + private let client: NativeHTTP |
| + init(client: NativeHTTP) { self.client = client } |
| + var hasUnsavedChanges: Bool { !pending.isEmpty || !editing.isEmpty || editingTask != nil } |
| + func load(query: String = "") async { |
| + do { |
| + let q = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" |
| + let response: DocumentList = try await client.request("/?q=\(q)") |
| + documents = response.documents; canCreate = response.can_create |
| + } catch { errorKey = "docs.error.generic" } |
| + } |
| + func create(title: String) async { |
| + do { |
| + var form = URLComponents(); form.queryItems = [URLQueryItem(name: "title", value: title)] |
| + let (data, status) = try await client.response("/documents", method: "POST", body: Data((form.percentEncodedQuery ?? "").utf8), contentType: "application/x-www-form-urlencoded") |
| + guard status == 201 else { throw CRDTReplica.Failure.rejected } |
| + let item = try JSONDecoder().decode(DocumentSummary.self, from: data) |
| + await load(); await open(item.sqid) |
| + } catch { errorKey = "docs.error.generic" } |
| + } |
| + func open(_ sqid: String) async { |
| + guard !hasUnsavedChanges else { errorKey = "native.pending"; return } |
| + streamTask?.cancel(); streamTask = nil |
| + if let replica { await replica.close() } |
| + loading = true; errorKey = nil |
| + defer { loading = false } |
| + do { |
| + let detail: DocumentDetail = try await client.request("/d/\(sqid)") |
| + let reply = try await client.responseDetails("/d/\(sqid)/updates", maxBytes: 8 * 1024 * 1024) |
| + guard reply.status == 200, let seq = Int64(reply.header("Docs-Seq") ?? "") else { throw CRDTReplica.Failure.rejected } |
| + let replica = CRDTReplica(guid: detail.guid) |
| + let projection = try await replica.open(update: reply.data) |
| + self.replica = replica; self.projection = projection; self.detail = detail; sequence = seq |
| + if projection.children.isEmpty && detail.can_write { |
| + enqueue(["op":.string("insert_element"), "at":.number(0), "type":.string("paragraph")]) |
| + } |
| + streamTask = Task { [weak self] in await self?.listen(sqid) } |
| + } catch { errorKey = "docs.error.generic"; detail = nil; projection = nil } |
| + } |
| + func enqueue(_ command: [String: JSONValue]) { |
| + guard detail?.can_write == true else { return } |
| + var command = command |
| + if command["op"] == .string("replace_text"), let typingMarks { command["marks"] = .object(typingMarks) } |
| + editing.append(command) |
| + if editingTask == nil { editingTask = Task { await drainEdits() } } |
| + } |
| + private func drainEdits() async { |
| + defer { editingTask = nil } |
| + while !editing.isEmpty, let replica { |
| + let command = editing.removeFirst() |
| + do { |
| + let (update, projected) = try await replica.command(command) |
| + pending.append(update) |
| + if editing.isEmpty { projection = projected } |
| + } catch { errorKey = "native.recovered"; editing.removeAll(); projection = try? await replica.projection(); break } |
| + } |
| + await flush() |
| + } |
| + func flush() async { |
| + guard !saving, let detail else { return }; saving = true |
| + defer { saving = false } |
| + while let update = pending.first { |
| + do { |
| + let (_, status) = try await client.response("/d/\(detail.sqid)/updates", method: "POST", body: update, contentType: "application/octet-stream") |
| + guard status == 200 else { throw CRDTReplica.Failure.rejected } |
| + pending.removeFirst() |
| + } catch { errorKey = "native.pending"; return } |
| + } |
| + errorKey = nil |
| + } |
| + private func listen(_ sqid: String) async { |
| + while !Task.isCancelled, detail?.sqid == sqid { |
| + do { |
| + try await client.stream("/d/\(sqid)/stream") { [weak self] line in |
| + if line.hasPrefix("data:"), let seq = Int64(line.dropFirst(5).trimmingCharacters(in: .whitespaces)) { |
| + await self?.refresh(sqid, remoteSequence: seq) |
| + } |
| + } |
| + } catch { if !Task.isCancelled { errorKey = "docs.editor.status.retrying" } } |
| + try? await Task.sleep(for: .seconds(2)) |
| + } |
| + } |
| + private func refresh(_ sqid: String, remoteSequence: Int64) async { |
| + 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 } |
| + let projected = try await replica.apply(reply.data) |
| + sequence = seq |
| + if editing.isEmpty && editingTask == nil { projection = projected } |
| + await flush() |
| + } catch { errorKey = "docs.editor.status.retrying" } |
| + } |
| +} |
diff --git a/native/apple/Sources/DocsKit/DocsModels.swift b/native/apple/Sources/DocsKit/DocsModels.swift| new file mode 100644 |
| index 0000000..d6904fe |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocsModels.swift |
| @@ -0,0 +1,16 @@ |
| +import Foundation |
| +struct DocumentSummary: Decodable, Identifiable, Sendable { |
| + let sqid: String |
| + let title: String |
| + var shared: Bool? |
| + var updated_at: String? |
| + var id: String { sqid } |
| +} |
| +struct DocumentList: Decodable, Sendable { let documents: [DocumentSummary]; let can_create: Bool } |
| +struct DocumentDetail: Decodable, Sendable { |
| + let sqid: String |
| + let title: String |
| + let guid: String |
| + let can_write: Bool |
| + let can_share: Bool |
| +} |
diff --git a/native/apple/Sources/DocsKit/DocsWorkspace.swift b/native/apple/Sources/DocsKit/DocsWorkspace.swift| new file mode 100644 |
| index 0000000..e0ebcf1 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/DocsWorkspace.swift |
| @@ -0,0 +1,54 @@ |
| +import RastrilloApple |
| +import SwiftUI |
| +@MainActor public struct DocsWorkspace: View { |
| + @State private var model: DocsModel |
| + @State private var query = "" |
| + @State private var newTitle = "" |
| + @State private var creating = false |
| + private let signOut: () -> Void |
| + public init(client: NativeHTTP, signOut: @escaping () -> Void) { |
| + _model = State(initialValue: DocsModel(client: client)); self.signOut = signOut |
| + } |
| + public var body: some View { |
| + NavigationSplitView { |
| + List { |
| + ForEach(model.documents) { document in |
| + Button { Task { await model.open(document.sqid) } } label: { |
| + Label(document.title, systemImage: document.shared == true ? "person.2" : "doc.text") |
| + }.buttonStyle(.plain) |
| + } |
| + } |
| + .searchable(text: $query, prompt: Text("docs.list.search.label", bundle: .module)) |
| + .onSubmit(of: .search) { Task { await model.load(query: query) } } |
| + .navigationTitle(Text("native.docs", bundle: .module)) |
| + .toolbar { |
| + if model.canCreate { Button { creating = true } label: { Label { Text("docs.list.create.label", bundle: .module) } icon: { Image(systemName: "plus") } } } |
| + Button { if !model.hasUnsavedChanges { signOut() } } label: { Text("docs.signout.label", bundle: .module) }.disabled(model.hasUnsavedChanges) |
| + } |
| + } detail: { |
| + VStack(spacing: 0) { |
| + if let key = model.errorKey { |
| + HStack { |
| + Text(LocalizedStringKey(key), bundle: .module) |
| + Button { Task { await model.flush() } } label: { Text("native.retry", bundle: .module) } |
| + }.padding().accessibilityElement(children: .contain) |
| + } |
| + if model.loading { ProgressView() } |
| + if let projection = model.projection { |
| + let text = NativeTextProjection.render(projection) |
| + EditorToolbar(model: model, content: text) |
| + NativeDocumentTextView(content: text, isEditable: model.detail?.can_write == true, selection: $model.selection, edit: model.enqueue) |
| + } else { |
| + ContentUnavailableView { Label { Text("native.open", bundle: .module) } icon: { Image(systemName: "doc.text") } } |
| + } |
| + }.navigationTitle(model.detail?.title ?? "") |
| + } |
| + .task { await model.load() } |
| + .sheet(isPresented: $creating) { |
| + Form { |
| + TextField(String(localized: "docs.list.create.placeholder", bundle: .module), text: $newTitle) |
| + Button { let title = newTitle; newTitle = ""; creating = false; Task { await model.create(title: title) } } label: { Text("docs.list.create.label", bundle: .module) } |
| + }.padding().presentationDetents([.medium]) |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/DocsKit/EditorToolbar.swift b/native/apple/Sources/DocsKit/EditorToolbar.swift| new file mode 100644 |
| index 0000000..2931ec8 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/EditorToolbar.swift |
| @@ -0,0 +1,60 @@ |
| +import SwiftUI |
| +@MainActor struct EditorToolbar: View { |
| + @Bindable var model: DocsModel |
| + let content: NSAttributedString |
| + var body: some View { |
| + ScrollView(.horizontal) { |
| + HStack(spacing: 12) { |
| + command("docs.editor.toolbar.undo", icon: "arrow.uturn.backward", op: "undo", enabled: model.projection?.canUndo == true) |
| + command("docs.editor.toolbar.redo", icon: "arrow.uturn.forward", op: "redo", enabled: model.projection?.canRedo == true) |
| + Divider().frame(height: 24) |
| + mark("docs.editor.toolbar.bold", icon: "bold", name: "strong") |
| + mark("docs.editor.toolbar.italic", icon: "italic", name: "em") |
| + mark("docs.editor.toolbar.code", icon: "chevron.left.forwardslash.chevron.right", name: "code") |
| + Menu { |
| + ForEach(["sans", "serif", "mono"], id: \.self) { family in |
| + Button { format("font_" + family, exclusive: ["font_sans", "font_serif", "font_mono"]) } label: { Text(LocalizedStringKey("docs.editor.toolbar.font." + family), bundle: .module) } |
| + } |
| + } label: { Image(systemName: "textformat") }.accessibilityLabel(Text("native.font", bundle: .module)) |
| + Menu { |
| + ForEach([12, 8, 9, 10, 11, 14, 18, 24, 36, 48, 72], id: \.self) { size in |
| + Button(size.formatted()) { format("size_\(size)", exclusive: [8,9,10,11,12,14,18,24,36,48,72].map { "size_\($0)" } + ["size_sm", "size_lg", "size_xl"]) } |
| + } |
| + } label: { Image(systemName: "textformat.size") }.accessibilityLabel(Text("native.size", bundle: .module)) |
| + ForEach(["left", "center", "right"], id: \.self) { alignment in |
| + Button { blockAttribute("align", value: alignment) } label: { Image(systemName: "text.align" + alignment) } |
| + .accessibilityLabel(Text(LocalizedStringKey("docs.editor.toolbar.align." + alignment), bundle: .module)) |
| + } |
| + }.buttonStyle(.borderless).padding(12) |
| + }.disabled(model.detail?.can_write != true) |
| + } |
| + private func command(_ key: String, icon: String, op: String, enabled: Bool) -> some View { |
| + Button { model.enqueue(["op":.string(op)]) } label: { Image(systemName: icon) } |
| + .accessibilityLabel(Text(LocalizedStringKey(key), bundle: .module)).disabled(!enabled) |
| + } |
| + private func mark(_ key: String, icon: String, name: String) -> some View { |
| + Button { format(name) } label: { Image(systemName: icon) }.accessibilityLabel(Text(LocalizedStringKey(key), bundle: .module)) |
| + } |
| + private func format(_ name: String, exclusive: [String] = []) { |
| + guard content.length > 0 else { return } |
| + let index = min(model.selection.location, content.length - 1) |
| + var marks = model.typingMarks ?? (content.attribute(.docsMarks, at: index, effectiveRange: nil) as? Data).flatMap { try? JSONDecoder().decode([String: JSONValue].self, from: $0) } ?? [:] |
| + let active = marks[name] != nil |
| + var applied: [String: JSONValue] = [:] |
| + for key in exclusive { marks.removeValue(forKey: key); applied[key] = .null } |
| + if active && exclusive.isEmpty { marks.removeValue(forKey: name); applied[name] = .null } |
| + else { marks[name] = .object([:]); applied[name] = .object([:]) } |
| + if model.selection.length == 0 { model.typingMarks = marks; return } |
| + content.enumerateAttribute(.docsNode, in: model.selection) { value, range, _ in |
| + guard value is String, var command = TextEditMapping.command(in: content, range: range, replacement: "") else { return } |
| + command["op"] = .string("format"); command["marks"] = .object(applied); command.removeValue(forKey: "text") |
| + model.enqueue(command) |
| + } |
| + } |
| + private func blockAttribute(_ name: String, value: String) { |
| + guard content.length > 0 else { return } |
| + let index = min(model.selection.location, content.length - 1) |
| + guard let block = content.attribute(.docsBlock, at: index, effectiveRange: nil) as? String else { return } |
| + model.enqueue(["op":.string("attribute"), "node":.string(block), "name":.string(name), "value":.string(value)]) |
| + } |
| +} |
diff --git a/native/apple/Sources/DocsKit/NativeDocumentTextView.swift b/native/apple/Sources/DocsKit/NativeDocumentTextView.swift| new file mode 100644 |
| index 0000000..daf1c99 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/NativeDocumentTextView.swift |
| @@ -0,0 +1,80 @@ |
| +import SwiftUI |
| +#if os(macOS) |
| +import AppKit |
| +struct NativeDocumentTextView: NSViewRepresentable { |
| + let content: NSAttributedString |
| + let isEditable: Bool |
| + @Binding var selection: NSRange |
| + let edit: @MainActor ([String: JSONValue]) -> Void |
| + func makeCoordinator() -> Coordinator { Coordinator(self) } |
| + func makeNSView(context: Context) -> NSScrollView { |
| + let scroll = NSTextView.scrollableTextView() |
| + let text = scroll.documentView as! NSTextView |
| + text.delegate = context.coordinator |
| + text.isRichText = true |
| + text.isAutomaticLinkDetectionEnabled = false |
| + text.isAutomaticTextReplacementEnabled = false |
| + text.textContainerInset = NSSize(width: 32, height: 28) |
| + text.allowsUndo = false |
| + text.setAccessibilityLabel(String(localized: "native.editor", bundle: .module)) |
| + return scroll |
| + } |
| + func updateNSView(_ scroll: NSScrollView, context: Context) { |
| + guard let text = scroll.documentView as? NSTextView else { return } |
| + context.coordinator.parent = self |
| + text.isEditable = isEditable |
| + if !text.hasMarkedText(), !text.attributedString().isEqual(to: content) { |
| + let selected = text.selectedRange() |
| + text.textStorage?.setAttributedString(content) |
| + text.setSelectedRange(NSRange(location: min(selected.location, content.length), length: min(selected.length, max(0, content.length - selected.location)))) |
| + } |
| + } |
| + @MainActor final class Coordinator: NSObject, NSTextViewDelegate { |
| + var parent: NativeDocumentTextView |
| + init(_ parent: NativeDocumentTextView) { self.parent = parent } |
| + func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange, replacementString: String?) -> Bool { |
| + guard let replacementString, let command = TextEditMapping.command(in: textView.attributedString(), range: affectedCharRange, replacement: replacementString) else { return false } |
| + parent.edit(command); return true |
| + } |
| + func textViewDidChangeSelection(_ notification: Notification) { |
| + guard let text = notification.object as? NSTextView else { return } |
| + parent.selection = text.selectedRange() |
| + } |
| + } |
| +} |
| +#else |
| +import UIKit |
| +struct NativeDocumentTextView: UIViewRepresentable { |
| + let content: NSAttributedString |
| + let isEditable: Bool |
| + @Binding var selection: NSRange |
| + let edit: @MainActor ([String: JSONValue]) -> Void |
| + func makeCoordinator() -> Coordinator { Coordinator(self) } |
| + func makeUIView(context: Context) -> UITextView { |
| + let text = UITextView() |
| + text.delegate = context.coordinator |
| + text.textContainerInset = UIEdgeInsets(top: 24, left: 20, bottom: 24, right: 20) |
| + text.adjustsFontForContentSizeCategory = true |
| + text.accessibilityLabel = String(localized: "native.editor", bundle: .module) |
| + return text |
| + } |
| + func updateUIView(_ text: UITextView, context: Context) { |
| + context.coordinator.parent = self |
| + text.isEditable = isEditable |
| + if text.markedTextRange == nil, !text.attributedText.isEqual(to: content) { |
| + let selected = text.selectedRange |
| + text.attributedText = content |
| + text.selectedRange = NSRange(location: min(selected.location, content.length), length: min(selected.length, max(0, content.length - selected.location))) |
| + } |
| + } |
| + @MainActor final class Coordinator: NSObject, UITextViewDelegate { |
| + var parent: NativeDocumentTextView |
| + init(_ parent: NativeDocumentTextView) { self.parent = parent } |
| + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { |
| + guard let command = TextEditMapping.command(in: textView.attributedText, range: range, replacement: text) else { return false } |
| + parent.edit(command); return true |
| + } |
| + func textViewDidChangeSelection(_ textView: UITextView) { parent.selection = textView.selectedRange } |
| + } |
| +} |
| +#endif |
diff --git a/native/apple/Sources/DocsKit/NativeTextProjection.swift b/native/apple/Sources/DocsKit/NativeTextProjection.swift| new file mode 100644 |
| index 0000000..1f43359 |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/NativeTextProjection.swift |
| @@ -0,0 +1,83 @@ |
| +import Foundation |
| +#if os(macOS) |
| +import AppKit |
| +private typealias PlatformFont = NSFont |
| +#else |
| +import UIKit |
| +private typealias PlatformFont = UIFont |
| +#endif |
| + |
| +extension NSAttributedString.Key { |
| + static let docsNode = NSAttributedString.Key("OficinaDocsNode") |
| + static let docsBlock = NSAttributedString.Key("OficinaDocsBlock") |
| + static let docsOffset = NSAttributedString.Key("OficinaDocsOffset") |
| + static let docsMarks = NSAttributedString.Key("OficinaDocsMarks") |
| +} |
| +@MainActor enum NativeTextProjection { |
| + static func render(_ projection: CRDTProjection) -> NSAttributedString { |
| + let output = NSMutableAttributedString(string: "") |
| + for node in projection.children { append(node, to: output, depth: 0) } |
| + return output |
| + } |
| + private static func append(_ node: CRDTNode, to output: NSMutableAttributedString, depth: Int) { |
| + if ["bullet_list", "ordered_list", "list_item", "blockquote"].contains(node.type) { |
| + for child in node.children ?? [] { append(child, to: output, depth: depth + (node.type == "list_item" || node.type == "blockquote" ? 1 : 0)) } |
| + return |
| + } |
| + let start = output.length |
| + let style = NSMutableParagraphStyle() |
| + style.paragraphSpacing = 8 |
| + style.headIndent = CGFloat(depth * 24 + (Int(node.attrs?["indent"] ?? "0") ?? 0) * 24) |
| + style.firstLineHeadIndent = style.headIndent |
| + switch node.attrs?["align"] { case "center": style.alignment = .center; case "right": style.alignment = .right; default: style.alignment = .left } |
| + let heading = node.type == "heading" ? Int(node.attrs?["level"] ?? "1") ?? 1 : 0 |
| + let base = heading > 0 ? max(14, 28 - CGFloat(heading * 3)) : 12 |
| + let baseAttributes: [NSAttributedString.Key: Any] = [.paragraphStyle: style, .font: font(marks: [:], size: base, heading: heading > 0), .docsBlock: node.id] |
| + if node.type == "horizontal_rule" { |
| + output.append(NSAttributedString(string: "────────\n", attributes: baseAttributes)); return |
| + } |
| + for child in node.children ?? [] { |
| + if child.type == "text" { |
| + var offset = 0 |
| + for run in child.runs ?? [] { |
| + var attributes = baseAttributes |
| + let marks = run.marks ?? [:] |
| + attributes[.font] = font(marks: marks, size: base, heading: heading > 0) |
| + attributes[.docsNode] = child.id |
| + attributes[.docsOffset] = offset |
| + attributes[.docsMarks] = try? JSONEncoder().encode(marks) |
| + if case .object(let link)? = marks["link"], case .string(let href)? = link["href"], let url = URL(string: href), ["https", "http", "mailto"].contains(url.scheme ?? "") { attributes[.link] = url } |
| + output.append(NSAttributedString(string: run.insert, attributes: attributes)) |
| + offset += run.insert.utf16.count |
| + } |
| + } else if child.type == "hard_break" { output.append(NSAttributedString(string: "\u{2028}", attributes: baseAttributes)) } |
| + else if child.type == "image" { output.append(NSAttributedString(string: child.attrs?["alt"] ?? "\u{fffc}", attributes: baseAttributes)) } |
| + else { append(child, to: output, depth: depth) } |
| + } |
| + var ending = baseAttributes |
| + if output.length == start { ending[.docsNode] = node.id; ending[.docsOffset] = 0 } |
| + output.append(NSAttributedString(string: "\n", attributes: ending)) |
| + } |
| + private static func font(marks: [String: JSONValue], size: CGFloat, heading: Bool) -> PlatformFont { |
| + var size = size |
| + for key in marks.keys where key.hasPrefix("size_") { |
| + if let value = Double(key.dropFirst(5)) { size = value } |
| + else if key == "size_sm" { size = 10 } else if key == "size_lg" { size = 14 } else if key == "size_xl" { size = 18 } |
| + } |
| + let mono = marks["font_mono"] != nil || marks["code"] != nil |
| + let serif = marks["font_serif"] != nil |
| + var font = mono ? PlatformFont.monospacedSystemFont(ofSize: size, weight: .regular) : PlatformFont.systemFont(ofSize: size) |
| + if serif { font = PlatformFont(name: "Georgia", size: size) ?? font } |
| + #if os(macOS) |
| + var traits: NSFontTraitMask = [] |
| + if marks["strong"] != nil || heading { traits.insert(.boldFontMask) } |
| + if marks["em"] != nil { traits.insert(.italicFontMask) } |
| + return NSFontManager.shared.convert(font, toHaveTrait: traits) |
| + #else |
| + var traits: UIFontDescriptor.SymbolicTraits = [] |
| + if marks["strong"] != nil || heading { traits.insert(.traitBold) } |
| + if marks["em"] != nil { traits.insert(.traitItalic) } |
| + return font.fontDescriptor.withSymbolicTraits(traits).map { UIFont(descriptor: $0, size: size) } ?? font |
| + #endif |
| + } |
| +} |
diff --git a/native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..a32f4ac |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings |
| @@ -0,0 +1,159 @@ |
| +"docs.document.untitled" = "Untitled document"; |
| +"docs.editor.menu.edit" = "Edit"; |
| +"docs.editor.menu.edit.find" = "Find and replace in this document"; |
| +"docs.editor.menu.edit.selectall" = "Select all"; |
| +"docs.editor.menu.file" = "File"; |
| +"docs.editor.menu.file.new" = "New document"; |
| +"docs.editor.menu.file.open" = "Open a document"; |
| +"docs.editor.menu.file.print" = "Print"; |
| +"docs.editor.menu.format" = "Format"; |
| +"docs.editor.menu.format.clear" = "Clear formatting"; |
| +"docs.editor.menu.format.indent" = "Increase indent"; |
| +"docs.editor.menu.format.outdent" = "Decrease indent"; |
| +"docs.editor.menu.insert" = "Insert"; |
| +"docs.editor.menu.insert.image" = "Image"; |
| +"docs.editor.menu.insert.link" = "Link"; |
| +"docs.editor.menu.insert.pagebreak" = "Page break"; |
| +"docs.editor.menu.insert.rule" = "Horizontal line"; |
| +"docs.editor.menu.insert.table" = "Table"; |
| +"docs.editor.menu.label" = "Menus"; |
| +"docs.editor.menu.tools" = "Tools"; |
| +"docs.editor.menu.tools.shortcuts" = "Keyboard shortcuts"; |
| +"docs.editor.menu.tools.wordcount" = "Word count"; |
| +"docs.editor.menu.view" = "View"; |
| +"docs.editor.menu.view.infinite" = "Infinite"; |
| +"docs.editor.menu.view.paged" = "Paged"; |
| +"docs.editor.menu.view.ruler" = "Show ruler"; |
| +"docs.editor.page.number" = "Page {n}"; |
| +"docs.editor.shortcuts.close" = "Close"; |
| +"docs.editor.shortcuts.editing" = "Writing"; |
| +"docs.editor.shortcuts.indent" = "Indent, or nest a list item"; |
| +"docs.editor.shortcuts.leave" = "Leave the document for the toolbar"; |
| +"docs.editor.shortcuts.linebreak" = "Break a line without starting a paragraph"; |
| +"docs.editor.shortcuts.outdent" = "Outdent, or lift a list item"; |
| +"docs.editor.shortcuts.title" = "Keyboard shortcuts"; |
| +"docs.editor.shortcuts.undoformat" = "Undo an autoformat, keeping what you typed"; |
| +"docs.editor.status.failed" = "Some changes couldn't be saved and were lost. Reload the page if this keeps happening."; |
| +"docs.editor.status.retrying" = "Trouble saving. Retrying…"; |
| +"docs.editor.toolbar.align.center" = "Align center"; |
| +"docs.editor.toolbar.align.left" = "Align left"; |
| +"docs.editor.toolbar.align.right" = "Align right"; |
| +"docs.editor.toolbar.block" = "Style"; |
| +"docs.editor.toolbar.block.heading1" = "Heading 1"; |
| +"docs.editor.toolbar.block.heading2" = "Heading 2"; |
| +"docs.editor.toolbar.block.heading3" = "Heading 3"; |
| +"docs.editor.toolbar.block.paragraph" = "Body text"; |
| +"docs.editor.toolbar.bold" = "Bold"; |
| +"docs.editor.toolbar.bulletlist" = "Bulleted list"; |
| +"docs.editor.toolbar.code" = "Code"; |
| +"docs.editor.toolbar.font" = "Font"; |
| +"docs.editor.toolbar.font.mono" = "Monospace"; |
| +"docs.editor.toolbar.font.sans" = "Sans serif"; |
| +"docs.editor.toolbar.font.serif" = "Serif"; |
| +"docs.editor.toolbar.italic" = "Italic"; |
| +"docs.editor.toolbar.label" = "Formatting"; |
| +"docs.editor.toolbar.orderedlist" = "Numbered list"; |
| +"docs.editor.toolbar.quote" = "Quote"; |
| +"docs.editor.toolbar.redo" = "Redo"; |
| +"docs.editor.toolbar.size" = "Size"; |
| +"docs.editor.toolbar.size.extralarge" = "Extra large"; |
| +"docs.editor.toolbar.size.large" = "Large"; |
| +"docs.editor.toolbar.size.normal" = "Normal"; |
| +"docs.editor.toolbar.size.small" = "Small"; |
| +"docs.editor.toolbar.undo" = "Undo"; |
| +"docs.editor.wordcount.characters" = "Characters"; |
| +"docs.editor.wordcount.charactersnospaces" = "Characters excluding spaces"; |
| +"docs.editor.wordcount.close" = "Close"; |
| +"docs.editor.wordcount.paragraphs" = "Paragraphs"; |
| +"docs.editor.wordcount.selection" = "Words in the selection"; |
| +"docs.editor.wordcount.title" = "Word count"; |
| +"docs.editor.wordcount.words" = "Words"; |
| +"docs.error.badsince" = "That update cursor is not valid."; |
| +"docs.error.badupdate" = "That update could not be applied."; |
| +"docs.error.generic" = "Something went wrong."; |
| +"docs.error.notfound" = "Not found."; |
| +"docs.error.updatetoolarge" = "That update is too large."; |
| +"docs.export.markdown.label" = "Download as Markdown"; |
| +"docs.export.markdown.short" = "Markdown"; |
| +"docs.export.pdf.label" = "Download as PDF"; |
| +"docs.export.pdf.short" = "PDF"; |
| +"docs.home.cancel_action" = "Back to Docs"; |
| +"docs.home.confirm_action" = "Connect accounts"; |
| +"docs.home.confirm_body" = "Connect the Docs account below to the Home account you just signed into."; |
| +"docs.home.confirm_title" = "Confirm account connection"; |
| +"docs.home.documents_action" = "Back to documents"; |
| +"docs.home.failed_body" = "Return to Docs and start again."; |
| +"docs.home.failed_title" = "Could not connect accounts"; |
| +"docs.home.local_action" = "Sign in to Docs"; |
| +"docs.home.menu_action" = "Connect Home account"; |
| +"docs.home.signedout_action" = "Sign in with Home"; |
| +"docs.home.signedout_body" = "You can sign in again with Home."; |
| +"docs.home.signedout_title" = "Signed out of Docs"; |
| +"docs.home.start_action" = "Continue to Home"; |
| +"docs.home.start_body" = "Use Home to sign in to this Docs account. Your documents and sharing settings stay as they are."; |
| +"docs.home.start_title" = "Connect Docs to Home"; |
| +"docs.home.success_body" = "You can now use Home to sign in to this Docs account."; |
| +"docs.home.success_title" = "Accounts connected"; |
| +"docs.home.unknown_body" = "Sign in to your existing Docs account, then connect Home from the account menu."; |
| +"docs.home.unknown_title" = "Home account not connected"; |
| +"docs.jumpto.close" = "Close"; |
| +"docs.jumpto.empty" = "No documents match"; |
| +"docs.jumpto.failed" = "Search is unavailable right now."; |
| +"docs.jumpto.hint" = "Type to search. Up and down to choose, Enter to open, Escape to close."; |
| +"docs.jumpto.label" = "Jump to a document"; |
| +"docs.jumpto.placeholder" = "Search documents"; |
| +"docs.list.create.label" = "New document"; |
| +"docs.list.create.placeholder" = "Title"; |
| +"docs.list.empty.body" = "Documents you create will show up here, newest first."; |
| +"docs.list.empty.title" = "No documents yet"; |
| +"docs.list.group.earlier" = "Earlier"; |
| +"docs.list.group.today" = "Today"; |
| +"docs.list.group.week" = "This week"; |
| +"docs.list.group.yesterday" = "Yesterday"; |
| +"docs.list.search.clear" = "Clear search"; |
| +"docs.list.search.label" = "Search documents"; |
| +"docs.list.search.none.body" = "Nothing here has that in its title. Check the spelling, or try a shorter word."; |
| +"docs.list.search.none.title" = "No documents match"; |
| +"docs.list.search.placeholder" = "Search"; |
| +"docs.list.shared" = "Shared with you"; |
| +"docs.list.title" = "Documents"; |
| +"docs.public.readonly" = "You are reading a shared copy of this document."; |
| +"docs.share.address" = "Email address"; |
| +"docs.share.back" = "Back to the document"; |
| +"docs.share.empty" = "Only you can open this document."; |
| +"docs.share.error.address" = "Type the email address of the person you want to share with."; |
| +"docs.share.heading" = "Who can open this"; |
| +"docs.share.public.body" = "Publish a link that anyone can open to read this document. They will not be able to change it, and they will not need to sign in."; |
| +"docs.share.public.heading" = "Anyone with a link"; |
| +"docs.share.public.hint" = "Publishing again makes a new link, and the old one stops working."; |
| +"docs.share.public.off" = "Stop publishing"; |
| +"docs.share.public.on" = "Publish a link"; |
| +"docs.share.public.once" = "Copy this link now. This is the only time it will be shown."; |
| +"docs.share.public.published" = "This document is published. The link was shown once when it was created and cannot be shown again — replace it if you no longer have it."; |
| +"docs.share.public.replace" = "Replace the link"; |
| +"docs.share.revoke" = "Stop sharing"; |
| +"docs.share.role" = "They can"; |
| +"docs.share.role.reader" = "Read it"; |
| +"docs.share.role.writer" = "Read and edit it"; |
| +"docs.share.submit" = "Share"; |
| +"docs.share.title" = "Share"; |
| +"docs.signin.address.label" = "Email address"; |
| +"docs.signin.body" = "Enter your email address to sign in."; |
| +"docs.signin.error.address" = "That doesn't look like a valid email address."; |
| +"docs.signin.error.expired" = "That link has expired or was already used. Enter your email below for a new one."; |
| +"docs.signin.error.rate" = "Too many attempts. Wait a few minutes and try again."; |
| +"docs.signin.error.unknown" = "We couldn't sign you in. Enter your email below for a new link."; |
| +"docs.signin.keymail" = "Continue to Keymail"; |
| +"docs.signin.sent" = "Check your email for a link to sign in."; |
| +"docs.signin.submit" = "Continue"; |
| +"docs.signin.title" = "Sign in"; |
| +"docs.signout.label" = "Sign out"; |
| +"docs.team.partial" = "We couldn’t check your team access. Only documents shared with you are shown."; |
| +"native.docs" = "Docs"; |
| +"native.editor" = "Document text"; |
| +"native.font" = "Font"; |
| +"native.open" = "Open a document"; |
| +"native.pending" = "Your changes are waiting to save. Keep this document open and try again."; |
| +"native.recovered" = "The editor recovered your saved changes. Undo history was cleared."; |
| +"native.retry" = "Try again"; |
| +"native.size" = "Text size"; |
diff --git a/native/apple/Sources/DocsKit/TextEditMapping.swift b/native/apple/Sources/DocsKit/TextEditMapping.swift| new file mode 100644 |
| index 0000000..431145a |
| --- /dev/null |
| +++ b/native/apple/Sources/DocsKit/TextEditMapping.swift |
| @@ -0,0 +1,22 @@ |
| +import Foundation |
| +@MainActor enum TextEditMapping { |
| + static func command(in text: NSAttributedString, range: NSRange, replacement: String) -> [String: JSONValue]? { |
| + guard text.length > 0, range.location <= text.length, NSMaxRange(range) <= text.length else { return nil } |
| + let index = min(range.location, text.length - 1) |
| + var run = NSRange() |
| + let attributes = text.attributes(at: index, effectiveRange: &run) |
| + guard let node = attributes[.docsNode] as? String else { return nil } |
| + if range.length > 0 { |
| + var valid = true |
| + text.enumerateAttribute(.docsNode, in: range) { value, _, stop in |
| + if value as? String != node { valid = false; stop.pointee = true } |
| + } |
| + guard valid else { return nil } |
| + } |
| + var nodeStart = index |
| + while nodeStart > 0 && text.attribute(.docsNode, at: nodeStart - 1, effectiveRange: nil) as? String == node { nodeStart -= 1 } |
| + let offset = range.location - nodeStart |
| + let marks = (attributes[.docsMarks] as? Data).flatMap { try? JSONDecoder().decode([String: JSONValue].self, from: $0) } ?? [:] |
| + return ["op":.string("replace_text"), "node":.string(node), "at":.number(Double(offset)), "length":.number(Double(range.length)), "text":.string(replacement), "marks":.object(marks)] |
| + } |
| +} |
diff --git a/native/crdt/Cargo.lock b/native/crdt/Cargo.lock| new file mode 100644 |
| index 0000000..136d1ef |
| --- /dev/null |
| +++ b/native/crdt/Cargo.lock |
| @@ -0,0 +1,347 @@ |
| +# This file is automatically @generated by Cargo. |
| +# It is not intended for manual editing. |
| +version = 4 |
| + |
| +[[package]] |
| +name = "arc-swap" |
| +version = "1.9.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" |
| +dependencies = [ |
| + "rustversion", |
| +] |
| + |
| +[[package]] |
| +name = "async-lock" |
| +version = "3.4.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" |
| +dependencies = [ |
| + "event-listener", |
| + "event-listener-strategy", |
| + "pin-project-lite", |
| +] |
| + |
| +[[package]] |
| +name = "async-trait" |
| +version = "0.1.92" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn", |
| +] |
| + |
| +[[package]] |
| +name = "bitflags" |
| +version = "2.13.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" |
| + |
| +[[package]] |
| +name = "cfg-if" |
| +version = "1.0.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" |
| + |
| +[[package]] |
| +name = "crossbeam-utils" |
| +version = "0.8.22" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" |
| + |
| +[[package]] |
| +name = "dashmap" |
| +version = "6.2.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" |
| +dependencies = [ |
| + "cfg-if", |
| + "crossbeam-utils", |
| + "hashbrown", |
| + "lock_api", |
| + "once_cell", |
| + "parking_lot_core", |
| +] |
| + |
| +[[package]] |
| +name = "docs-native-crdt" |
| +version = "0.1.0" |
| +dependencies = [ |
| + "getrandom", |
| + "serde_json", |
| + "yrs", |
| +] |
| + |
| +[[package]] |
| +name = "event-listener" |
| +version = "5.4.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" |
| +dependencies = [ |
| + "parking", |
| + "pin-project-lite", |
| +] |
| + |
| +[[package]] |
| +name = "event-listener-strategy" |
| +version = "0.5.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" |
| +dependencies = [ |
| + "event-listener", |
| + "pin-project-lite", |
| +] |
| + |
| +[[package]] |
| +name = "fastrand" |
| +version = "2.5.0" |
| +dependencies = [ |
| + "getrandom", |
| +] |
| + |
| +[[package]] |
| +name = "getrandom" |
| +version = "0.4.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" |
| +dependencies = [ |
| + "cfg-if", |
| + "libc", |
| + "r-efi", |
| +] |
| + |
| +[[package]] |
| +name = "hashbrown" |
| +version = "0.14.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" |
| + |
| +[[package]] |
| +name = "itoa" |
| +version = "1.0.18" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
| + |
| +[[package]] |
| +name = "libc" |
| +version = "0.2.189" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" |
| + |
| +[[package]] |
| +name = "lock_api" |
| +version = "0.4.14" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" |
| +dependencies = [ |
| + "scopeguard", |
| +] |
| + |
| +[[package]] |
| +name = "memchr" |
| +version = "2.8.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| + |
| +[[package]] |
| +name = "once_cell" |
| +version = "1.21.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" |
| + |
| +[[package]] |
| +name = "parking" |
| +version = "2.2.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" |
| + |
| +[[package]] |
| +name = "parking_lot_core" |
| +version = "0.9.12" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" |
| +dependencies = [ |
| + "cfg-if", |
| + "libc", |
| + "redox_syscall", |
| + "smallvec", |
| + "windows-link", |
| +] |
| + |
| +[[package]] |
| +name = "pin-project-lite" |
| +version = "0.2.17" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" |
| + |
| +[[package]] |
| +name = "proc-macro2" |
| +version = "1.0.107" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" |
| +dependencies = [ |
| + "unicode-ident", |
| +] |
| + |
| +[[package]] |
| +name = "quote" |
| +version = "1.0.47" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" |
| +dependencies = [ |
| + "proc-macro2", |
| +] |
| + |
| +[[package]] |
| +name = "r-efi" |
| +version = "6.0.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" |
| + |
| +[[package]] |
| +name = "redox_syscall" |
| +version = "0.5.18" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" |
| +dependencies = [ |
| + "bitflags", |
| +] |
| + |
| +[[package]] |
| +name = "rustversion" |
| +version = "1.0.23" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" |
| + |
| +[[package]] |
| +name = "scopeguard" |
| +version = "1.2.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" |
| + |
| +[[package]] |
| +name = "serde" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" |
| +dependencies = [ |
| + "serde_core", |
| + "serde_derive", |
| +] |
| + |
| +[[package]] |
| +name = "serde_core" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" |
| +dependencies = [ |
| + "serde_derive", |
| +] |
| + |
| +[[package]] |
| +name = "serde_derive" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn", |
| +] |
| + |
| +[[package]] |
| +name = "serde_json" |
| +version = "1.0.151" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" |
| +dependencies = [ |
| + "itoa", |
| + "memchr", |
| + "serde", |
| + "serde_core", |
| + "zmij", |
| +] |
| + |
| +[[package]] |
| +name = "smallstr" |
| +version = "0.3.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "862077b1e764f04c251fe82a2ef562fd78d7cadaeb072ca7c2bcaf7217b1ff3b" |
| +dependencies = [ |
| + "smallvec", |
| +] |
| + |
| +[[package]] |
| +name = "smallvec" |
| +version = "1.15.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" |
| + |
| +[[package]] |
| +name = "syn" |
| +version = "3.0.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "unicode-ident", |
| +] |
| + |
| +[[package]] |
| +name = "thiserror" |
| +version = "2.0.20" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" |
| +dependencies = [ |
| + "thiserror-impl", |
| +] |
| + |
| +[[package]] |
| +name = "thiserror-impl" |
| +version = "2.0.20" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn", |
| +] |
| + |
| +[[package]] |
| +name = "unicode-ident" |
| +version = "1.0.24" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" |
| + |
| +[[package]] |
| +name = "windows-link" |
| +version = "0.2.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" |
| + |
| +[[package]] |
| +name = "yrs" |
| +version = "0.23.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "197c2b4b298f35c3ba4d549884ac872a961112095b29f173ab45e3d5cf609018" |
| +dependencies = [ |
| + "arc-swap", |
| + "async-lock", |
| + "async-trait", |
| + "dashmap", |
| + "fastrand", |
| + "serde", |
| + "serde_json", |
| + "smallstr", |
| + "smallvec", |
| + "thiserror", |
| +] |
| + |
| +[[package]] |
| +name = "zmij" |
| +version = "1.0.23" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
diff --git a/native/crdt/Cargo.toml b/native/crdt/Cargo.toml| new file mode 100644 |
| index 0000000..8b246d6 |
| --- /dev/null |
| +++ b/native/crdt/Cargo.toml |
| @@ -0,0 +1,22 @@ |
| +[package] |
| +name = "docs-native-crdt" |
| +version = "0.1.0" |
| +edition = "2021" |
| + |
| +[lib] |
| +crate-type = ["cdylib", "rlib"] |
| + |
| +[dependencies] |
| +yrs = "=0.23.5" |
| +serde_json = "=1.0.151" |
| + |
| +[target.'cfg(target_arch = "wasm32")'.dependencies] |
| +getrandom = "=0.4.3" |
| + |
| +[patch.crates-io] |
| +fastrand = { path = "../../core/vendor/fastrand-2.5.0" } |
| + |
| +[profile.release] |
| +opt-level = "s" |
| +lto = true |
| +panic = "abort" |
diff --git a/native/crdt/src/lib.rs b/native/crdt/src/lib.rs| new file mode 100644 |
| index 0000000..947c7a9 |
| --- /dev/null |
| +++ b/native/crdt/src/lib.rs |
| @@ -0,0 +1,137 @@ |
| +//! Native editor CRDT operations. In production this crate runs ONLY inside |
| +//! a fuel/memory-limited WebAssembly interpreter, never as a native decoder. |
| +//! The separate module leaves the server's embedded ABI and artifact unchanged. |
| +mod projection; |
| +mod operations; |
| +mod structure; |
| +use serde_json::{json, Value}; |
| +use std::{cell::RefCell, collections::HashSet, sync::Arc}; |
| +use yrs::{Doc, OffsetKind, Options, ReadTxn, StateVector, Transact, UndoManager, Update}; |
| +use yrs::updates::decoder::{Decode, Decoder, DecoderV1}; |
| + |
| +pub struct Editor { |
| + doc: Doc, |
| + undo: UndoManager, |
| +} |
| +impl Editor { |
| + pub fn new(guid: &str, client_id: u64) -> Self { |
| + let mut options = Options::with_guid_and_client_id(guid.into(), client_id); |
| + options.offset_kind = OffsetKind::Utf16; |
| + let doc = Doc::with_options(options); |
| + let root = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let undo = UndoManager::with_scope_and_options(&doc, &root, yrs::undo::Options { |
| + capture_timeout_millis: 0, |
| + tracked_origins: HashSet::from(["native-edit".into()]), |
| + capture_transaction: None, |
| + timestamp: Arc::new(|| 1), |
| + }); |
| + Self {doc, undo} |
| + } |
| + pub fn apply(&self, bytes: &[u8]) -> Result<(), &'static str> { |
| + let mut remaining = bytes; |
| + let mut updates = Vec::new(); |
| + while !remaining.is_empty() { |
| + let mut decoder = DecoderV1::from(remaining); |
| + updates.push(Update::decode(&mut decoder).map_err(|_| "invalid_update")?); |
| + let trailing = decoder.read_to_end().map_err(|_| "invalid_update")?.len(); |
| + if trailing >= remaining.len() { return Err("invalid_update"); } |
| + remaining = &remaining[remaining.len() - trailing..]; |
| + } |
| + let mut txn = self.doc.transact_mut_with("remote"); |
| + for update in updates { txn.apply_update(update).map_err(|_| "invalid_update")?; } |
| + Ok(()) |
| + } |
| + pub fn snapshot(&self) -> Vec<u8> { |
| + self.doc.transact().encode_state_as_update_v1(&StateVector::default()) |
| + } |
| + pub fn projection(&self) -> Value { |
| + let root = self.doc.get_or_insert_xml_fragment("prosemirror"); |
| + let txn = self.doc.transact(); |
| + json!({"children": projection::children(&root, &txn), "canUndo": !self.undo.undo_stack().is_empty(), "canRedo": !self.undo.redo_stack().is_empty()}) |
| + } |
| + pub fn command(&mut self, request: &Value) -> Result<Vec<u8>, &'static str> { |
| + let before = self.doc.transact().state_vector(); |
| + match request.get("op").and_then(Value::as_str).ok_or("invalid_command")? { |
| + "undo" => { self.undo.undo_blocking(); }, |
| + "redo" => { self.undo.redo_blocking(); }, |
| + "block" | "split" => structure::execute(&self.doc, request)?, |
| + _ => operations::execute(&self.doc, request)?, |
| + } |
| + Ok(self.doc.transact().encode_state_as_update_v1(&before)) |
| + } |
| +} |
| + |
| +thread_local! { |
| + static EDITOR: RefCell<Option<Editor>> = const { RefCell::new(None) }; |
| + static OUTPUT: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) }; |
| +} |
| + |
| +// All pointers are private WASM linear-memory offsets passed by the interpreter. |
| +// No API in this module may be called with native pointers by an Apple process. |
| +#[cfg(target_arch = "wasm32")] |
| +#[no_mangle] |
| +pub extern "C" fn docs_editor_alloc(len: u32) -> u32 { |
| + if len > 8 * 1024 * 1024 + 1 { return 0; } |
| + Box::into_raw(vec![0u8; len as usize].into_boxed_slice()) as *mut u8 as u32 |
| +} |
| +#[cfg(target_arch = "wasm32")] |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_editor_free(ptr: u32, len: u32) { |
| + if ptr != 0 { drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr as *mut u8, len as usize))); } |
| +} |
| +#[cfg(target_arch = "wasm32")] |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_editor_call(ptr: u32, len: u32) -> i32 { |
| + let end = (ptr as u64) + (len as u64); |
| + if len > 8 * 1024 * 1024 + 1 || end > (core::arch::wasm32::memory_size(0) * 65536) as u64 { return -1; } |
| + let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize); |
| + let result = dispatch(bytes); |
| + match result { |
| + Ok(bytes) => { OUTPUT.with(|out| *out.borrow_mut() = bytes); 0 }, |
| + Err(code) => { OUTPUT.with(|out| *out.borrow_mut() = code.as_bytes().to_vec()); -1 }, |
| + } |
| +} |
| +#[cfg(target_arch = "wasm32")] |
| +#[no_mangle] |
| +pub extern "C" fn docs_editor_output_ptr() -> u32 { OUTPUT.with(|out| out.borrow().as_ptr() as u32) } |
| +#[cfg(target_arch = "wasm32")] |
| +#[no_mangle] |
| +pub extern "C" fn docs_editor_output_len() -> u32 { OUTPUT.with(|out| out.borrow().len() as u32) } |
| + |
| +pub fn dispatch(bytes: &[u8]) -> Result<Vec<u8>, &'static str> { |
| + // Binary envelope avoids expanding an 8MiB log into JSON byte numbers. |
| + if bytes.first() == Some(&255) { |
| + return EDITOR.with(|cell| { |
| + cell.borrow().as_ref().ok_or("not_open")?.apply(&bytes[1..])?; |
| + Ok(Vec::new()) |
| + }); |
| + } |
| + let request: Value = serde_json::from_slice(bytes).map_err(|_| "invalid_command")?; |
| + EDITOR.with(|cell| { |
| + let mut state = cell.borrow_mut(); |
| + if request["op"] == "open" { |
| + let guid = request["guid"].as_str().ok_or("invalid_identity")?; |
| + let client = request["client"].as_u64().filter(|v| *v > 0 && *v < (1u64 << 53)).ok_or("invalid_identity")?; |
| + *state = Some(Editor::new(guid, client)); |
| + return Ok(Vec::new()); |
| + } |
| + let editor = state.as_mut().ok_or("not_open")?; |
| + match request["op"].as_str() { |
| + Some("apply") => { |
| + let bytes = request["bytes"].as_array().ok_or("invalid_update")?.iter() |
| + .map(|v| v.as_u64().filter(|n| *n <= 255).map(|n| n as u8).ok_or("invalid_update")) |
| + .collect::<Result<Vec<_>, _>>()?; |
| + editor.apply(&bytes)?; Ok(Vec::new()) |
| + }, |
| + Some("snapshot") => Ok(editor.snapshot()), |
| + Some("project") => serde_json::to_vec(&editor.projection()).map_err(|_| "invalid_projection"), |
| + _ => editor.command(&request), |
| + } |
| + }) |
| +} |
| + |
| +#[cfg(target_arch = "wasm32")] |
| +#[no_mangle] |
| +extern "Rust" fn __getrandom_v03_custom(_dest: *mut u8, _len: usize) -> Result<(), getrandom::Error> { |
| + Err(getrandom::Error::UNSUPPORTED) |
| +} |
diff --git a/native/crdt/src/operations.rs b/native/crdt/src/operations.rs| new file mode 100644 |
| index 0000000..a190b14 |
| --- /dev/null |
| +++ b/native/crdt/src/operations.rs |
| @@ -0,0 +1,95 @@ |
| +use crate::projection; |
| +use serde_json::Value; |
| +use yrs::{Any, Doc, Text, Transact, Xml, XmlElementPrelim, XmlFragment, XmlFragmentRef, XmlOut, XmlTextPrelim}; |
| +use yrs::types::Attrs; |
| + |
| +fn number(request: &Value, name: &str) -> Result<u32, &'static str> { |
| + request[name].as_u64().filter(|n| *n <= u32::MAX as u64).map(|n| n as u32).ok_or("invalid_range") |
| +} |
| +fn string<'a>(request: &'a Value, name: &str) -> Result<&'a str, &'static str> { |
| + request[name].as_str().ok_or("invalid_command") |
| +} |
| +// Accept only scalar-boundary UTF16 offsets. A cursor in the middle of an |
| +// emoji must be rejected before yrs can split its internal UTF8 sequence. |
| +fn boundary(text: &str, position: u32) -> bool { |
| + let mut offset = 0; |
| + for ch in text.chars() { |
| + if offset == position { return true; } |
| + offset += ch.len_utf16() as u32; |
| + } |
| + offset == position |
| +} |
| +fn attrs(value: &Value) -> Result<Attrs, &'static str> { |
| + if value.is_null() { return Ok(Attrs::new()); } |
| + value.as_object().ok_or("invalid_marks")?.iter().map(|(key,value)| { |
| + let any: Any = serde_json::from_value(value.clone()).map_err(|_| "invalid_marks")?; |
| + Ok((key.as_str().into(), any)) |
| + }).collect() |
| +} |
| +pub fn execute(doc: &Doc, request: &Value) -> Result<(), &'static str> { |
| + let root = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let op = string(request, "op")?; |
| + let mut txn = doc.transact_mut_with("native-edit"); |
| + let id = request["node"].as_str().unwrap_or(""); |
| + let node = projection::find(&root, &txn, id); |
| + match op { |
| + "replace_text" | "format" => { |
| + let text = match node.ok_or("missing_node")? { |
| + XmlOut::Text(text) => text, |
| + XmlOut::Element(element) if element.len(&txn) == 0 && op == "replace_text" => { |
| + if number(request, "at")? != 0 || number(request, "length")? != 0 { return Err("invalid_range"); } |
| + element.insert(&mut txn, 0, XmlTextPrelim::new("")) |
| + }, |
| + XmlOut::Element(element) => match element.get(&txn, 0) { Some(XmlOut::Text(t)) => t, _ => return Err("not_text") }, |
| + _ => return Err("not_text"), |
| + }; |
| + let at = number(request, "at")?; |
| + let length = number(request, "length")?; |
| + let end = at.checked_add(length).ok_or("invalid_range")?; |
| + // XmlText GetString includes formatting markup; Text::get_string does |
| + // too for XML. Build raw text from deltas for offset validation. |
| + let raw: String = text.diff(&txn, |_| ()).iter().map(|r| r.insert.clone().to_string(&txn)).collect(); |
| + if !boundary(&raw, at) || !boundary(&raw, end) { return Err("invalid_range"); } |
| + let marks = attrs(&request["marks"])?; |
| + if op == "format" { text.format(&mut txn, at, length, marks); } |
| + else { |
| + let replacement = string(request, "text")?; |
| + if length > 0 { text.remove_range(&mut txn, at, length); } |
| + text.insert_with_attributes(&mut txn, at, replacement, marks); |
| + } |
| + }, |
| + "attribute" => { |
| + let XmlOut::Element(element) = node.ok_or("missing_node")? else { return Err("not_element"); }; |
| + let name = string(request, "name")?; |
| + if request["value"].is_null() { element.remove_attribute(&mut txn, &name); } |
| + else { element.insert_attribute(&mut txn, name, string(request, "value")?); } |
| + }, |
| + "insert_element" | "insert_text" | "remove_nodes" => { |
| + let parent: XmlFragmentRef = if id.is_empty() { root } else { match node.ok_or("missing_node")? { |
| + XmlOut::Element(e) => AsRef::<XmlFragmentRef>::as_ref(&e).clone(), |
| + XmlOut::Fragment(f) => f, |
| + _ => return Err("not_container"), |
| + }}; |
| + let at = number(request, "at")?; |
| + if at > parent.len(&txn) { return Err("invalid_range"); } |
| + match op { |
| + "insert_element" => { |
| + let tag = string(request, "type")?; |
| + let mut prelim = XmlElementPrelim::empty(tag); |
| + if let Some(attributes) = request["attrs"].as_object() { |
| + for (key, value) in attributes { prelim.attributes.insert(key.as_str().into(), value.as_str().ok_or("invalid_attribute")?.into()); } |
| + } |
| + parent.insert(&mut txn, at, prelim); |
| + }, |
| + "insert_text" => { parent.insert(&mut txn, at, XmlTextPrelim::new(string(request, "text")?)); }, |
| + _ => { |
| + let length = number(request, "length")?; |
| + if at.checked_add(length).ok_or("invalid_range")? > parent.len(&txn) { return Err("invalid_range"); } |
| + parent.remove_range(&mut txn, at, length); |
| + }, |
| + } |
| + }, |
| + _ => return Err("unknown_command"), |
| + } |
| + Ok(()) |
| +} |
diff --git a/native/crdt/src/projection.rs b/native/crdt/src/projection.rs| new file mode 100644 |
| index 0000000..fc837b5 |
| --- /dev/null |
| +++ b/native/crdt/src/projection.rs |
| @@ -0,0 +1,38 @@ |
| +use serde_json::{json, Value}; |
| +use yrs::{ReadTxn, Text, Xml, XmlFragment, XmlFragmentRef, XmlOut}; |
| + |
| +pub fn node_id(node: &XmlOut) -> String { |
| + serde_json::to_string(&node.id()).expect("branch identity serializes") |
| +} |
| +pub fn children<T: ReadTxn>(root: &XmlFragmentRef, txn: &T) -> Vec<Value> { |
| + (0..root.len(txn)).filter_map(|i| root.get(txn, i)).map(|node| project(&node, txn)).collect() |
| +} |
| +fn project<T: ReadTxn>(node: &XmlOut, txn: &T) -> Value { |
| + match node { |
| + XmlOut::Text(text) => { |
| + let runs: Vec<_> = text.diff(txn, |_| ()).iter().map(|run| { |
| + json!({"insert": run.insert.clone().to_string(txn), "marks": run.attributes}) |
| + }).collect(); |
| + json!({"id": node_id(node), "type": "text", "runs": runs}) |
| + }, |
| + XmlOut::Element(element) => { |
| + let attrs: serde_json::Map<String, Value> = element.attributes(txn) |
| + .map(|(key,value)| (key.to_string(), Value::String(value))).collect(); |
| + json!({"id": node_id(node), "type": element.tag().as_ref(), "attrs": attrs, "children": children(element.as_ref(), txn)}) |
| + }, |
| + XmlOut::Fragment(fragment) => json!({"id": node_id(node), "type": "fragment", "children": children(fragment, txn)}), |
| + } |
| +} |
| +pub fn find<T: ReadTxn>(root: &XmlFragmentRef, txn: &T, id: &str) -> Option<XmlOut> { |
| + for index in 0..root.len(txn) { |
| + let node = root.get(txn, index)?; |
| + if node_id(&node) == id { return Some(node); } |
| + let nested = match &node { |
| + XmlOut::Element(el) => find(el.as_ref(), txn, id), |
| + XmlOut::Fragment(f) => find(f, txn, id), |
| + _ => None, |
| + }; |
| + if nested.is_some() { return nested; } |
| + } |
| + None |
| +} |
diff --git a/native/crdt/src/structure.rs b/native/crdt/src/structure.rs| new file mode 100644 |
| index 0000000..93e6f36 |
| --- /dev/null |
| +++ b/native/crdt/src/structure.rs |
| @@ -0,0 +1,77 @@ |
| +use crate::projection; |
| +use serde_json::Value; |
| +use yrs::{Any, Doc, In, ReadTxn, Text, Transact, XmlElementPrelim, XmlFragment, XmlFragmentRef, XmlOut}; |
| +use yrs::types::{AsPrelim, Delta}; |
| +use yrs::types::xml::{XmlDeltaPrelim, XmlIn}; |
| + |
| +fn parent<T: ReadTxn>(root: &XmlFragmentRef, txn: &T, id: &str) -> Option<(XmlFragmentRef, u32)> { |
| + for index in 0..root.len(txn) { |
| + let node = root.get(txn, index)?; |
| + if projection::node_id(&node) == id { return Some((root.clone(), index)); } |
| + let found = match node { XmlOut::Element(e) => parent(e.as_ref(), txn, id), XmlOut::Fragment(f) => parent(&f, txn, id), _ => None }; |
| + if found.is_some() { return found; } |
| + } |
| + None |
| +} |
| +fn number(value: &Value) -> Result<u32, &'static str> { value.as_u64().and_then(|v| v.try_into().ok()).ok_or("invalid_range") } |
| +fn slice(text: &str, from: u32) -> Result<String, &'static str> { |
| + let units: Vec<_> = text.encode_utf16().collect(); |
| + if from as usize > units.len() { return Err("invalid_range"); } |
| + String::from_utf16(&units[from as usize..]).map_err(|_| "invalid_range") |
| +} |
| +pub fn execute(doc: &Doc, request: &Value) -> Result<(), &'static str> { |
| + let root = doc.get_or_insert_xml_fragment("prosemirror"); |
| + let id = request["node"].as_str().ok_or("missing_node")?; |
| + let mut txn = doc.transact_mut_with("native-edit"); |
| + let node = projection::find(&root, &txn, id).ok_or("missing_node")?; |
| + match request["op"].as_str() { |
| + Some("block") => { |
| + let XmlOut::Element(element) = node else { return Err("not_element"); }; |
| + let (parent, index) = parent(&root, &txn, id).ok_or("missing_parent")?; |
| + let tag = request["type"].as_str().ok_or("invalid_type")?; |
| + let mut copy = element.as_prelim(&txn); |
| + if ["paragraph", "heading", "code_block"].contains(&tag) { |
| + copy.tag = tag.into(); |
| + if tag == "heading" { copy.attributes.insert("level".into(), number(&request["level"])?.clamp(1,6).to_string()); } |
| + else { copy.attributes.remove("level"); } |
| + } else if ["bullet_list", "ordered_list"].contains(&tag) { |
| + copy = XmlElementPrelim::new(tag, [XmlElementPrelim::new("list_item", [XmlIn::Element(copy)]).into()]); |
| + } else if tag == "blockquote" { copy = XmlElementPrelim::new(tag, [XmlIn::Element(copy)]); } |
| + else { return Err("invalid_type"); } |
| + parent.remove(&mut txn, index); |
| + parent.insert(&mut txn, index, copy); |
| + }, |
| + Some("split") => { |
| + let XmlOut::Text(text) = node else { return Err("not_text"); }; |
| + let at = number(&request["at"])?; |
| + let (block_fragment, text_index) = parent(&root, &txn, id).ok_or("missing_parent")?; |
| + let block_id = projection::node_id(&XmlOut::Fragment(block_fragment.clone())); |
| + let (container, block_index) = parent(&root, &txn, &block_id).ok_or("missing_parent")?; |
| + let Some(XmlOut::Element(block)) = container.get(&txn, block_index) else { return Err("not_block"); }; |
| + let mut remaining = at; |
| + let mut delta = Vec::new(); |
| + for diff in text.diff(&txn, |_| ()) { |
| + let raw = diff.insert.to_string(&txn); |
| + let length = raw.encode_utf16().count() as u32; |
| + if remaining > length { remaining -= length; continue; } |
| + let suffix = slice(&raw, remaining)?; remaining = 0; |
| + if !suffix.is_empty() { delta.push(Delta::Inserted(In::Any(Any::from(suffix)), diff.attributes)); } |
| + } |
| + if remaining > 0 || at > text.len(&txn) { return Err("invalid_range"); } |
| + let suffix = XmlDeltaPrelim { attributes: Default::default(), delta }; |
| + let mut copy = block.as_prelim(&txn); |
| + copy.children = vec![XmlIn::Text(suffix)]; |
| + for index in text_index + 1..block.len(&txn) { |
| + copy.children.push(match block.get(&txn,index).ok_or("missing_node")? { XmlOut::Element(e) => e.as_prelim(&txn).into(), XmlOut::Text(t) => t.as_prelim(&txn).into(), XmlOut::Fragment(f) => XmlIn::Fragment(f.as_prelim(&txn)) }); |
| + } |
| + let tail = text.len(&txn) - at; |
| + if tail == 0 && block.tag().as_ref() == "heading" { copy.tag = "paragraph".into(); copy.attributes.remove("level"); } |
| + text.remove_range(&mut txn, at, tail); |
| + let extra = block.len(&txn) - text_index - 1; |
| + if extra > 0 { block.remove_range(&mut txn, text_index + 1, extra); } |
| + container.insert(&mut txn, block_index + 1, copy); |
| + }, |
| + _ => return Err("unknown_command"), |
| + } |
| + Ok(()) |
| +} |
diff --git a/native/crdt/tests/replica.rs b/native/crdt/tests/replica.rs| new file mode 100644 |
| index 0000000..9be16ac |
| --- /dev/null |
| +++ b/native/crdt/tests/replica.rs |
| @@ -0,0 +1,64 @@ |
| +use docs_native_crdt::Editor; |
| +use serde_json::{json, Value}; |
| + |
| +fn paragraph(editor: &mut Editor, text: &str) -> (String, String) { |
| + editor.command(&json!({"op":"insert_element", "at":0, "type":"paragraph"})).unwrap(); |
| + let block = editor.projection()["children"][0]["id"].as_str().unwrap().to_owned(); |
| + editor.command(&json!({"op":"insert_text", "node":block, "at":0, "text":text})).unwrap(); |
| + let node = editor.projection()["children"][0]["children"][0]["id"].as_str().unwrap().to_owned(); |
| + (block, node) |
| +} |
| +fn plain(projection: &Value) -> String { |
| + if let Some(runs) = projection["runs"].as_array() { |
| + return runs.iter().map(|r| r["insert"].as_str().unwrap()).collect(); |
| + } |
| + projection["children"].as_array().map(|items| items.iter().map(plain).collect()).unwrap_or_default() |
| +} |
| +#[test] |
| +fn utf16_edits_preserve_identity_and_marks() { |
| + let mut editor = Editor::new("fixture", 1); |
| + let (block, node) = paragraph(&mut editor, "A😀B"); |
| + editor.command(&json!({"op":"format", "node":node, "at":1, "length":2, "marks":{"strong":{}}})).unwrap(); |
| + editor.command(&json!({"op":"replace_text", "node":node, "at":3, "length":1, "text":"C"})).unwrap(); |
| + let p = editor.projection(); |
| + assert_eq!(plain(&p), "A😀C"); |
| + assert_eq!(p["children"][0]["id"], block); |
| + assert_eq!(p["children"][0]["children"][0]["id"], node); |
| + assert_eq!(p["children"][0]["children"][0]["runs"][1]["marks"]["strong"], json!({})); |
| + assert!(editor.command(&json!({"op":"replace_text", "node":node, "at":2, "length":0, "text":"broken"})).is_err()); |
| + assert_eq!(plain(&editor.projection()), "A😀C"); |
| +} |
| +#[test] |
| +fn concurrent_insertions_and_own_undo_preserve_peer() { |
| + let mut a = Editor::new("fixture", 2); |
| + let (_, node) = paragraph(&mut a, "Start"); |
| + let mut b = Editor::new("fixture", 3); |
| + b.apply(&a.snapshot()).unwrap(); |
| + let local = a.command(&json!({"op":"replace_text", "node":node, "at":5, "length":0, "text":" alice"})).unwrap(); |
| + let peer = b.command(&json!({"op":"replace_text", "node":node, "at":0, "length":0, "text":"bob "})).unwrap(); |
| + a.apply(&peer).unwrap(); b.apply(&local).unwrap(); |
| + assert_eq!(plain(&a.projection()), plain(&b.projection())); |
| + let undo = a.command(&json!({"op":"undo"})).unwrap(); b.apply(&undo).unwrap(); |
| + assert_eq!(plain(&a.projection()), "bob Start"); |
| + assert_eq!(plain(&a.projection()), plain(&b.projection())); |
| +} |
| +#[test] |
| +fn concatenated_log_applies_every_update() { |
| + let mut a = Editor::new("fixture", 4); |
| + let (_, node) = paragraph(&mut a, "one"); |
| + let mut log = a.snapshot(); |
| + log.extend(a.command(&json!({"op":"replace_text", "node":node, "at":3, "length":0, "text":" two"})).unwrap()); |
| + let b = Editor::new("fixture", 5); |
| + b.apply(&log).unwrap(); |
| + assert_eq!(plain(&b.projection()), "one two"); |
| +} |
| +#[test] |
| +fn reads_existing_yjs_fragment_without_rewriting() { |
| + let editor = Editor::new("fixture", 6); |
| + editor.apply(include_bytes!("../../../fixtures/vectors/fragment/formatted.update.bin")).unwrap(); |
| + let before = editor.projection(); |
| + let other = Editor::new("fixture", 7); |
| + other.apply(&editor.snapshot()).unwrap(); |
| + assert_eq!(before["children"], other.projection()["children"]); |
| + assert!(!plain(&before).is_empty()); |
| +} |
diff --git a/native/host/Cargo.lock b/native/host/Cargo.lock| new file mode 100644 |
| index 0000000..9e3f032 |
| --- /dev/null |
| +++ b/native/host/Cargo.lock |
| @@ -0,0 +1,310 @@ |
| +# This file is automatically @generated by Cargo. |
| +# It is not intended for manual editing. |
| +version = 4 |
| + |
| +[[package]] |
| +name = "ahash" |
| +version = "0.8.12" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" |
| +dependencies = [ |
| + "cfg-if", |
| + "once_cell", |
| + "version_check", |
| + "zerocopy", |
| +] |
| + |
| +[[package]] |
| +name = "arrayvec" |
| +version = "0.7.8" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" |
| + |
| +[[package]] |
| +name = "autocfg" |
| +version = "1.5.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" |
| + |
| +[[package]] |
| +name = "cfg-if" |
| +version = "1.0.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" |
| + |
| +[[package]] |
| +name = "docs-native-host" |
| +version = "0.1.0" |
| +dependencies = [ |
| + "serde_json", |
| + "wasmi", |
| +] |
| + |
| +[[package]] |
| +name = "downcast-rs" |
| +version = "1.2.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" |
| + |
| +[[package]] |
| +name = "hashbrown" |
| +version = "0.14.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" |
| +dependencies = [ |
| + "ahash", |
| +] |
| + |
| +[[package]] |
| +name = "indexmap-nostd" |
| +version = "0.4.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" |
| + |
| +[[package]] |
| +name = "itoa" |
| +version = "1.0.18" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
| + |
| +[[package]] |
| +name = "libm" |
| +version = "0.2.16" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" |
| + |
| +[[package]] |
| +name = "memchr" |
| +version = "2.8.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" |
| + |
| +[[package]] |
| +name = "multi-stash" |
| +version = "0.2.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f" |
| + |
| +[[package]] |
| +name = "num-derive" |
| +version = "0.4.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn 2.0.119", |
| +] |
| + |
| +[[package]] |
| +name = "num-traits" |
| +version = "0.2.19" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" |
| +dependencies = [ |
| + "autocfg", |
| +] |
| + |
| +[[package]] |
| +name = "once_cell" |
| +version = "1.21.4" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" |
| + |
| +[[package]] |
| +name = "paste" |
| +version = "1.0.15" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" |
| + |
| +[[package]] |
| +name = "proc-macro2" |
| +version = "1.0.107" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" |
| +dependencies = [ |
| + "unicode-ident", |
| +] |
| + |
| +[[package]] |
| +name = "quote" |
| +version = "1.0.47" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" |
| +dependencies = [ |
| + "proc-macro2", |
| +] |
| + |
| +[[package]] |
| +name = "serde" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" |
| +dependencies = [ |
| + "serde_core", |
| +] |
| + |
| +[[package]] |
| +name = "serde_core" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" |
| +dependencies = [ |
| + "serde_derive", |
| +] |
| + |
| +[[package]] |
| +name = "serde_derive" |
| +version = "1.0.229" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn 3.0.5", |
| +] |
| + |
| +[[package]] |
| +name = "serde_json" |
| +version = "1.0.151" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" |
| +dependencies = [ |
| + "itoa", |
| + "memchr", |
| + "serde", |
| + "serde_core", |
| + "zmij", |
| +] |
| + |
| +[[package]] |
| +name = "smallvec" |
| +version = "1.15.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" |
| + |
| +[[package]] |
| +name = "spin" |
| +version = "0.9.9" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" |
| + |
| +[[package]] |
| +name = "string-interner" |
| +version = "0.17.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "1c6a0d765f5807e98a091107bae0a56ea3799f66a5de47b2c84c94a39c09974e" |
| +dependencies = [ |
| + "cfg-if", |
| + "hashbrown", |
| + "serde", |
| +] |
| + |
| +[[package]] |
| +name = "syn" |
| +version = "2.0.119" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "unicode-ident", |
| +] |
| + |
| +[[package]] |
| +name = "syn" |
| +version = "3.0.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "unicode-ident", |
| +] |
| + |
| +[[package]] |
| +name = "unicode-ident" |
| +version = "1.0.24" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" |
| + |
| +[[package]] |
| +name = "version_check" |
| +version = "0.9.5" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" |
| + |
| +[[package]] |
| +name = "wasmi" |
| +version = "0.32.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "50386c99b9c32bd2ed71a55b6dd4040af2580530fae8bdb9a6576571a80d0cca" |
| +dependencies = [ |
| + "arrayvec", |
| + "multi-stash", |
| + "num-derive", |
| + "num-traits", |
| + "smallvec", |
| + "spin", |
| + "wasmi_collections", |
| + "wasmi_core", |
| + "wasmparser-nostd", |
| +] |
| + |
| +[[package]] |
| +name = "wasmi_collections" |
| +version = "0.32.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "9c128c039340ffd50d4195c3f8ce31aac357f06804cfc494c8b9508d4b30dca4" |
| +dependencies = [ |
| + "ahash", |
| + "hashbrown", |
| + "string-interner", |
| +] |
| + |
| +[[package]] |
| +name = "wasmi_core" |
| +version = "0.32.3" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "a23b3a7f6c8c3ceeec6b83531ee61f0013c56e51cbf2b14b0f213548b23a4b41" |
| +dependencies = [ |
| + "downcast-rs", |
| + "libm", |
| + "num-traits", |
| + "paste", |
| +] |
| + |
| +[[package]] |
| +name = "wasmparser-nostd" |
| +version = "0.100.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" |
| +dependencies = [ |
| + "indexmap-nostd", |
| +] |
| + |
| +[[package]] |
| +name = "zerocopy" |
| +version = "0.8.56" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" |
| +dependencies = [ |
| + "zerocopy-derive", |
| +] |
| + |
| +[[package]] |
| +name = "zerocopy-derive" |
| +version = "0.8.56" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" |
| +dependencies = [ |
| + "proc-macro2", |
| + "quote", |
| + "syn 2.0.119", |
| +] |
| + |
| +[[package]] |
| +name = "zmij" |
| +version = "1.0.23" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
diff --git a/native/host/Cargo.toml b/native/host/Cargo.toml| new file mode 100644 |
| index 0000000..3205c64 |
| --- /dev/null |
| +++ b/native/host/Cargo.toml |
| @@ -0,0 +1,10 @@ |
| +[package] |
| +name = "docs-native-host" |
| +version = "0.1.0" |
| +edition = "2021" |
| +[lib] |
| +crate-type = ["staticlib", "rlib"] |
| +[dependencies] |
| +wasmi = "=0.32.3" |
| +[dev-dependencies] |
| +serde_json = "=1.0.151" |
diff --git a/native/host/include/DocsCRDT.h b/native/host/include/DocsCRDT.h| new file mode 100644 |
| index 0000000..6c944aa |
| --- /dev/null |
| +++ b/native/host/include/DocsCRDT.h |
| @@ -0,0 +1,8 @@ |
| +#ifndef DOCS_CRDT_H |
| +#define DOCS_CRDT_H |
| +#include <stdint.h> |
| +#include <stddef.h> |
| +void *docs_host_new(void); |
| +void docs_host_free(void *host); |
| +int64_t docs_host_call(void *host, const uint8_t *input, size_t length, uint8_t *output, size_t capacity); |
| +#endif |
diff --git a/native/host/include/module.modulemap b/native/host/include/module.modulemap| new file mode 100644 |
| index 0000000..f05c7a1 |
| --- /dev/null |
| +++ b/native/host/include/module.modulemap |
| @@ -0,0 +1,4 @@ |
| +module DocsCRDT { |
| + header "DocsCRDT.h" |
| + export * |
| +} |
diff --git a/native/host/src/lib.rs b/native/host/src/lib.rs| new file mode 100644 |
| index 0000000..015b875 |
| --- /dev/null |
| +++ b/native/host/src/lib.rs |
| @@ -0,0 +1,80 @@ |
| +//! Apple process boundary: only the safe WASM interpreter sees remote updates. |
| +use std::panic::{catch_unwind, AssertUnwindSafe}; |
| +use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder}; |
| +const MAX_BYTES: usize = 8 * 1024 * 1024 + 1; |
| +const FUEL: u64 = 40_000_000; |
| +const WASM: &[u8] = include_bytes!("../../crdt/target/wasm32-unknown-unknown/release/docs_native_crdt.wasm"); |
| +pub struct Host { |
| + store: Store<StoreLimits>, |
| + instance: Instance, |
| + memory: Memory, |
| + output: Vec<u8>, |
| + failed: bool, |
| +} |
| +impl Host { |
| + pub fn new() -> Result<Self, &'static str> { |
| + let mut config = Config::default(); |
| + config.consume_fuel(true); |
| + let engine = Engine::new(&config); |
| + let module = Module::new(&engine, WASM).map_err(|_| "invalid_module")?; |
| + // No imports are supplied: CRDT must remain independent of host JS, |
| + // filesystem, entropy and clock functions. Identity is an input. |
| + let linker = Linker::new(&engine); |
| + let limits = StoreLimitsBuilder::new().memory_size(64 * 1024 * 1024).instances(1).memories(1).tables(1).build(); |
| + let mut store = Store::new(&engine, limits); |
| + store.limiter(|limits| limits); |
| + store.set_fuel(FUEL).map_err(|_| "fuel")?; |
| + let instance = linker.instantiate(&mut store, &module).map_err(|_| "invalid_module")?.start(&mut store).map_err(|_| "module_trap")?; |
| + let memory = instance.get_memory(&store, "memory").ok_or("invalid_module")?; |
| + Ok(Self {store, instance, memory, output: Vec::new(), failed: false}) |
| + } |
| + pub fn call(&mut self, request: &[u8]) -> Result<&[u8], &'static str> { |
| + if self.failed || request.len() > MAX_BYTES { return Err("replica_unavailable"); } |
| + // Any failed mutation can have partially modified the WASM state. Once |
| + // failed, discard the replica; never let callers continue using it. |
| + self.failed = true; |
| + self.store.set_fuel(FUEL).map_err(|_| "fuel")?; |
| + let alloc = self.instance.get_typed_func::<u32, u32>(&self.store, "docs_editor_alloc").map_err(|_| "invalid_abi")?; |
| + let ptr = alloc.call(&mut self.store, request.len() as u32).map_err(|_| "module_trap")?; |
| + if ptr == 0 { return Err("allocation"); } |
| + self.memory.write(&mut self.store, ptr as usize, request).map_err(|_| "invalid_memory")?; |
| + let call = self.instance.get_typed_func::<(u32,u32), i32>(&self.store, "docs_editor_call").map_err(|_| "invalid_abi")?; |
| + let status = call.call(&mut self.store, (ptr, request.len() as u32)).map_err(|_| "module_trap")?; |
| + let free = self.instance.get_typed_func::<(u32,u32), ()>(&self.store, "docs_editor_free").map_err(|_| "invalid_abi")?; |
| + free.call(&mut self.store, (ptr, request.len() as u32)).map_err(|_| "module_trap")?; |
| + if status != 0 { return Err("rejected_command"); } |
| + let length = self.instance.get_typed_func::<(),u32>(&self.store, "docs_editor_output_len").map_err(|_| "invalid_abi")?.call(&mut self.store, ()).map_err(|_| "module_trap")? as usize; |
| + let start = self.instance.get_typed_func::<(),u32>(&self.store, "docs_editor_output_ptr").map_err(|_| "invalid_abi")?.call(&mut self.store, ()).map_err(|_| "module_trap")? as usize; |
| + if length > MAX_BYTES { return Err("oversized_output"); } |
| + self.output.resize(length, 0); |
| + self.memory.read(&self.store, start, &mut self.output).map_err(|_| "invalid_memory")?; |
| + self.failed = false; |
| + Ok(&self.output) |
| + } |
| +} |
| +#[no_mangle] |
| +pub extern "C" fn docs_host_new() -> *mut Host { |
| + catch_unwind(|| Host::new().map(|h| Box::into_raw(Box::new(h))).unwrap_or(std::ptr::null_mut())).unwrap_or(std::ptr::null_mut()) |
| +} |
| +/// # Safety |
| +/// `host` must be returned by docs_host_new, uniquely owned, and freed once. |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_host_free(host: *mut Host) { |
| + if !host.is_null() { let _ = catch_unwind(AssertUnwindSafe(|| drop(Box::from_raw(host)))); } |
| +} |
| +/// # Safety |
| +/// Host/input/output must be valid for their stated lengths. The caller serializes calls. |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_host_call(host: *mut Host, input: *const u8, length: usize, output: *mut u8, capacity: usize) -> i64 { |
| + if host.is_null() || input.is_null() || output.is_null() || length > MAX_BYTES || capacity > MAX_BYTES { return -1; } |
| + catch_unwind(AssertUnwindSafe(|| { |
| + let host = &mut *host; |
| + let bytes = std::slice::from_raw_parts(input, length); |
| + match host.call(bytes) { |
| + Ok(result) if result.len() <= capacity => { |
| + std::ptr::copy_nonoverlapping(result.as_ptr(), output, result.len()); result.len() as i64 |
| + }, |
| + _ => { host.failed = true; -1 }, |
| + } |
| + })).unwrap_or_else(|_| { (*host).failed = true; -1 }) |
| +} |
diff --git a/native/host/tests/sandbox.rs b/native/host/tests/sandbox.rs| new file mode 100644 |
| index 0000000..38950e3 |
| --- /dev/null |
| +++ b/native/host/tests/sandbox.rs |
| @@ -0,0 +1,23 @@ |
| +use docs_native_host::Host; |
| +use serde_json::json; |
| +#[test] |
| +fn interpreter_executes_native_crdt_without_host_imports() { |
| + let mut host = Host::new().unwrap(); |
| + host.call(br#"{"op":"open","guid":"fixture","client":123}"#).unwrap(); |
| + host.call(br#"{"op":"insert_element","at":0,"type":"paragraph"}"#).unwrap(); |
| + let p: serde_json::Value = serde_json::from_slice(host.call(br#"{"op":"project"}"#).unwrap()).unwrap(); |
| + assert_eq!(p["children"][0]["type"], "paragraph"); |
| + let node = p["children"][0]["id"].as_str().unwrap(); |
| + host.call(&serde_json::to_vec(&json!({"op":"insert_text","node":node,"at":0,"text":"Sandbox 😀"})).unwrap()).unwrap(); |
| + let p: serde_json::Value = serde_json::from_slice(host.call(br#"{"op":"project"}"#).unwrap()).unwrap(); |
| + assert_eq!(p["children"][0]["children"][0]["runs"][0]["insert"], "Sandbox 😀"); |
| +} |
| +#[test] |
| +fn failed_replica_cannot_be_reused() { |
| + let mut host = Host::new().unwrap(); |
| + host.call(br#"{"op":"open","guid":"fixture","client":456}"#).unwrap(); |
| + assert!(host.call(br#"{"op":"apply","bytes":[255,255,255]}"#).is_err()); |
| + assert!(host.call(br#"{"op":"project"}"#).is_err()); |
| + let mut clean = Host::new().unwrap(); |
| + assert!(clean.call(br#"{"op":"open","guid":"fixture","client":789}"#).is_ok()); |
| +} |
diff --git a/native/ios/project.yml b/native/ios/project.yml| new file mode 100644 |
| index 0000000..4a954d1 |
| --- /dev/null |
| +++ b/native/ios/project.yml |
| @@ -0,0 +1,33 @@ |
| +name: DocsPhone |
| +options: |
| + bundleIdPrefix: net.amadan.oficina |
| + deploymentTarget: |
| + iOS: '26.0' |
| +packages: |
| + DocsNative: |
| + path: ../apple |
| + RastrilloNative: |
| + path: ../../../rastrillo-native-editors |
| +settings: |
| + base: |
| + SWIFT_VERSION: '6.0' |
| + CODE_SIGN_STYLE: Automatic |
| + MARKETING_VERSION: '0.1.0' |
| + CURRENT_PROJECT_VERSION: '1' |
| +targets: |
| + DocsPhone: |
| + type: application |
| + platform: iOS |
| + sources: [../apple/Sources/DocsApp] |
| + dependencies: |
| + - package: DocsNative |
| + product: DocsKit |
| + - package: RastrilloNative |
| + product: RastrilloApple |
| + settings: |
| + base: |
| + PRODUCT_BUNDLE_IDENTIFIER: net.amadan.oficina.docs.ios |
| + TARGETED_DEVICE_FAMILY: '1,2' |
| + GENERATE_INFOPLIST_FILE: YES |
| + INFOPLIST_KEY_CFBundleDisplayName: Oficina Docs |
| + INFOPLIST_KEY_UILaunchScreen_Generation: YES |
diff --git a/native/scripts/build-crdt.sh b/native/scripts/build-crdt.sh| new file mode 100755 |
| index 0000000..69c071a |
| --- /dev/null |
| +++ b/native/scripts/build-crdt.sh |
| @@ -0,0 +1,17 @@ |
| +#!/bin/sh |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| +# A single preparation pass creates all slices before simultaneous app builds. |
| +# Set RUSTC/CARGO explicitly when Rust is installed outside the normal PATH. |
| +CARGO=${CARGO:-cargo} |
| +if [ -n "${DOCS_LLVM_LIBRARY_PATH:-}" ]; then export DYLD_LIBRARY_PATH="$DOCS_LLVM_LIBRARY_PATH"; fi |
| +"$CARGO" build --manifest-path crdt/Cargo.toml --release --target wasm32-unknown-unknown --lib --locked |
| +for target in aarch64-apple-darwin aarch64-apple-ios aarch64-apple-ios-sim; do |
| + "$CARGO" build --manifest-path host/Cargo.toml --release --target "$target" --lib --locked |
| +done |
| +rm -rf apple/Artifacts/DocsCRDT.xcframework |
| +xcodebuild -create-xcframework \ |
| + -library "$PWD/host/target/aarch64-apple-darwin/release/libdocs_native_host.a" -headers "$PWD/host/include" \ |
| + -library "$PWD/host/target/aarch64-apple-ios/release/libdocs_native_host.a" -headers "$PWD/host/include" \ |
| + -library "$PWD/host/target/aarch64-apple-ios-sim/release/libdocs_native_host.a" -headers "$PWD/host/include" \ |
| + -output "$PWD/apple/Artifacts/DocsCRDT.xcframework" |
diff --git a/native/scripts/build-iphone.sh b/native/scripts/build-iphone.sh| new file mode 100755 |
| index 0000000..5a0ecf8 |
| --- /dev/null |
| +++ b/native/scripts/build-iphone.sh |
| @@ -0,0 +1,10 @@ |
| +#!/bin/sh |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| +if [ "${OFICINA_SKIP_PROJECT_GENERATION:-0}" != 1 ]; then xcodegen generate --spec ios/project.yml; fi |
| +case "${1:-simulator}" in |
| + simulator) xcodebuild -project ios/DocsPhone.xcodeproj -scheme DocsPhone -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath build/ios CODE_SIGNING_ALLOWED=NO build ;; |
| + device) : "${OFICINA_DEVELOPMENT_TEAM:?Set your Apple development team ID}" |
| + xcodebuild -project ios/DocsPhone.xcodeproj -scheme DocsPhone -destination 'generic/platform=iOS' -derivedDataPath build/device "DEVELOPMENT_TEAM=$OFICINA_DEVELOPMENT_TEAM" -allowProvisioningUpdates build ;; |
| + *) exit 2 ;; |
| +esac |
diff --git a/native/scripts/build-mac.sh b/native/scripts/build-mac.sh| new file mode 100755 |
| index 0000000..75f79a9 |
| --- /dev/null |
| +++ b/native/scripts/build-mac.sh |
| @@ -0,0 +1,4 @@ |
| +#!/bin/sh |
| +set -eu |
| +cd "$(dirname "$0")/.." |
| +swift build --package-path apple --product DocsMac |