Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Packages/OsaurusCore/Managers/Plugin/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ final class PluginManager {
if !loaded.skills.isEmpty {
await SkillManager.shared.unregisterPluginSkills(pluginId: loaded.plugin.id)
}
PluginDocumentRegistry.unregisterAll(pluginId: loaded.plugin.id)
await loaded.plugin.shutdown()
PluginHostContext.getContext(for: loaded.plugin.id)?.teardown()
lastPushedTunnelURL.removeValue(forKey: loaded.plugin.id)
Expand Down Expand Up @@ -266,6 +267,7 @@ final class PluginManager {
if !loaded.skills.isEmpty {
await SkillManager.shared.unregisterPluginSkills(pluginId: loaded.plugin.id)
}
PluginDocumentRegistry.unregisterAll(pluginId: loaded.plugin.id)
await loaded.plugin.shutdown()
PluginHostContext.getContext(for: loaded.plugin.id)?.teardown()
lastPushedTunnelURL.removeValue(forKey: loaded.plugin.id)
Expand Down
10 changes: 10 additions & 0 deletions Packages/OsaurusCore/Models/Plugin/ExternalPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,11 @@ typealias osr_log_structured_t =
// Host-side string free (added in v6)
typealias osr_host_free_string_t = @convention(c) (UnsafePointer<CChar>?) -> Void

// Document-format registration (added in v7)
typealias osr_register_parser_t = @convention(c) (UnsafePointer<CChar>?) -> UnsafePointer<CChar>?
typealias osr_register_emitter_t = @convention(c) (UnsafePointer<CChar>?) -> UnsafePointer<CChar>?
typealias osr_unregister_format_t = @convention(c) (UnsafePointer<CChar>?) -> UnsafePointer<CChar>?

/// Frozen layout — field order and padding must match `osaurus_plugin.h`
/// exactly (see `PluginHostAPIStructLayoutTests`). Swift plugins that mirror
/// this struct must not skip middle fields (e.g. omitting v5 `log_structured`
Expand Down Expand Up @@ -181,6 +186,11 @@ struct osr_host_api {

// Host-side string free (added in v6)
var free_string: osr_host_free_string_t?

// Document-format registration (added in v7)
var register_parser: osr_register_parser_t?
var register_emitter: osr_register_emitter_t?
var unregister_format: osr_unregister_format_t?
}

struct osr_plugin_api {
Expand Down
135 changes: 135 additions & 0 deletions Packages/OsaurusCore/Services/Plugin/PluginBackedDocumentAdapter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//
// PluginBackedDocumentAdapter.swift
// osaurus
//
// Swift shims that implement `DocumentFormatAdapter` / `DocumentFormatEmitter`
// by forwarding to an external plugin via the existing
// `invoke(type:id:payload:)` callback. Created when a plugin calls the
// register_parser / register_emitter host callbacks from its `init` entry
// point.
//
// The plugin side speaks JSON; these shims translate between the
// typed `DocumentFormatRegistry` contract and the plugin's wire format.
// Only the `textFallback` representation is surfaced today — richer
// types (`Workbook`, `PDFDocumentRepresentation`) are left for future
// PRs that extend the plugin response schema.
//

import Foundation

/// Opaque invoker the plugin host wires up so shims don't need to know
/// about `ExternalPlugin` internals. The host side (PluginHostAPI) is
/// the only producer; tests use a closure-backed fake.
public protocol PluginDocumentInvoker: Sendable {
func invoke(type: String, id: String, payload: String) async -> String
}

struct PluginBackedAdapter: DocumentFormatAdapter {
let formatId: String
let extensions: Set<String>
let invoker: any PluginDocumentInvoker

func canHandle(url: URL, uti: String?) -> Bool {
extensions.contains(url.pathExtension.lowercased())
}

func parse(url: URL, sizeLimit: Int64) async throws -> StructuredDocument {
let fileSize = Int64((try? url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0)
if sizeLimit > 0, fileSize > sizeLimit {
throw DocumentAdapterError.sizeLimitExceeded(actual: fileSize, limit: sizeLimit)
}

let payload: [String: Any] = [
"path": url.path,
"size_limit": Int(sizeLimit),
]
let payloadJSON = Self.serialize(payload)
let responseText = await invoker.invoke(type: "parser", id: formatId, payload: payloadJSON)

guard let response = Self.decodeResponse(responseText) else {
throw DocumentAdapterError.readFailed(
underlying: "plugin returned malformed parser response for format '\(formatId)'"
)
}
if response.ok == false {
throw DocumentAdapterError.readFailed(
underlying: response.error ?? "plugin parser failed for format '\(formatId)'"
)
}

let text = response.textFallback ?? ""
guard !text.isEmpty else {
throw DocumentAdapterError.emptyContent
}

return StructuredDocument(
formatId: formatId,
filename: response.filename ?? url.lastPathComponent,
fileSize: response.fileSize ?? fileSize,
representation: AnyStructuredRepresentation(
formatId: formatId,
underlying: PlainTextRepresentation(text: text)
),
textFallback: text
)
}

// MARK: - Response parsing

struct ParserResponse {
var ok: Bool
var error: String?
var textFallback: String?
var filename: String?
var fileSize: Int64?
}

static func decodeResponse(_ raw: String) -> ParserResponse? {
guard let data = raw.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else { return nil }
return ParserResponse(
ok: (obj["ok"] as? Bool) ?? false,
error: obj["error"] as? String,
textFallback: obj["text_fallback"] as? String,
filename: obj["filename"] as? String,
fileSize: (obj["file_size"] as? NSNumber)?.int64Value
)
}

static func serialize(_ dict: [String: Any]) -> String {
(try? JSONSerialization.data(withJSONObject: dict))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
}
}

struct PluginBackedEmitter: DocumentFormatEmitter {
let formatId: String
let invoker: any PluginDocumentInvoker

func canEmit(_ document: StructuredDocument) -> Bool {
document.formatId == formatId
}

func emit(_ document: StructuredDocument, to url: URL) async throws {
let payload: [String: Any] = [
"destination": url.path,
"filename": document.filename,
"text": document.textFallback,
]
let payloadJSON = PluginBackedAdapter.serialize(payload)
let responseText = await invoker.invoke(type: "emitter", id: formatId, payload: payloadJSON)

guard let data = responseText.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
throw DocumentAdapterError.writeFailed(
underlying: "plugin returned malformed emitter response for format '\(formatId)'"
)
}
if (obj["ok"] as? Bool) != true {
let message = (obj["error"] as? String) ?? "plugin emitter failed"
throw DocumentAdapterError.writeFailed(underlying: message)
}
}
}
159 changes: 159 additions & 0 deletions Packages/OsaurusCore/Services/Plugin/PluginDocumentRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//
// PluginDocumentRegistry.swift
// osaurus
//
// Bridge between the plugin lifecycle and `DocumentFormatRegistry`.
// Plugins reach it through host callbacks (introduced in v7 as
// `register_parser` / `register_emitter` entry points in the osaurus
// plugin ABI header) while Swift callers use this module directly.
//
// Responsibilities:
// - Translate the plugin's JSON registration request into a
// `DocumentFormatAdapter` / `DocumentFormatEmitter` shim.
// - Track which plugin owns which `formatId` so a plugin unload can
// tear down only its own adapters, not an in-tree one that
// happens to share a format name.
// - Return a stable JSON response shape so the plugin side can log /
// report failures without needing Swift types.
//

import Foundation

public enum PluginDocumentRegistry {
private static let lock = NSLock()
// Maps format_id -> plugin_id. Keeps ownership out of the shared
// `DocumentFormatRegistry` (which has no concept of owners) so one
// plugin cannot unregister another's format.
nonisolated(unsafe) private static var ownership: [String: String] = [:]

/// Register a plugin-backed parser. `requestJSON` matches the
/// `register_parser` contract from `osaurus_plugin.h`. On success
/// the shim adapter immediately appears in `DocumentFormatRegistry.shared`.
@discardableResult
public static func registerParser(
requestJSON: String,
invoker: any PluginDocumentInvoker,
registry: DocumentFormatRegistry = .shared
) -> String {
guard let req = parse(requestJSON) else {
return response(ok: false, error: "request JSON must include `plugin_id`, `format_id`, and `extensions`")
}
if !claimOwnership(pluginId: req.pluginId, formatId: req.formatId) {
return response(
ok: false,
error: "format '\(req.formatId)' already registered by another plugin"
)
}
registry.register(
adapter: PluginBackedAdapter(
formatId: req.formatId,
extensions: req.extensions,
invoker: invoker
)
)
return response(ok: true)
}

/// Register a plugin-backed emitter. Same JSON shape as `registerParser`;
/// the `extensions` field is accepted but ignored because emitter
/// routing is `canEmit(document)`-based.
@discardableResult
public static func registerEmitter(
requestJSON: String,
invoker: any PluginDocumentInvoker,
registry: DocumentFormatRegistry = .shared
) -> String {
guard let req = parse(requestJSON) else {
return response(ok: false, error: "request JSON must include `plugin_id` and `format_id`")
}
if !claimOwnership(pluginId: req.pluginId, formatId: req.formatId) {
return response(
ok: false,
error: "format '\(req.formatId)' already registered by another plugin"
)
}
registry.register(
emitter: PluginBackedEmitter(formatId: req.formatId, invoker: invoker)
)
return response(ok: true)
}

/// Unregister every adapter/emitter/streamer associated with a
/// plugin-owned `formatId`. Declines to unregister in-tree formats
/// so a buggy plugin can't strip the built-in XLSX / PDF adapters.
@discardableResult
public static func unregisterFormat(
requestJSON: String,
registry: DocumentFormatRegistry = .shared
) -> String {
guard let req = parse(requestJSON) else {
return response(ok: false, error: "request JSON must include `plugin_id` and `format_id`")
}
guard releaseOwnership(pluginId: req.pluginId, formatId: req.formatId) else {
return response(ok: false, error: "format '\(req.formatId)' not owned by this plugin")
}
registry.unregisterAll(formatId: req.formatId)
return response(ok: true)
}

/// Tear down every format a plugin registered. Called by
/// `PluginManager` during plugin unload so the shared registry
/// doesn't carry stale pointers.
public static func unregisterAll(pluginId: String, registry: DocumentFormatRegistry = .shared) {
lock.lock()
let owned = ownership.filter { $0.value == pluginId }.map(\.key)
for format in owned { ownership.removeValue(forKey: format) }
lock.unlock()
for format in owned {
registry.unregisterAll(formatId: format)
}
}

// MARK: - Internals

struct Request {
let pluginId: String
let formatId: String
let extensions: Set<String>
}

static func parse(_ json: String) -> Request? {
guard let data = json.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let pluginId = obj["plugin_id"] as? String, !pluginId.isEmpty,
let formatId = obj["format_id"] as? String, !formatId.isEmpty
else { return nil }
let rawExtensions = (obj["extensions"] as? [String]) ?? []
let cleaned = rawExtensions.map {
$0.hasPrefix(".") ? String($0.dropFirst()) : $0
}
return Request(
pluginId: pluginId,
formatId: formatId,
extensions: Set(cleaned.map { $0.lowercased() })
)
}

private static func claimOwnership(pluginId: String, formatId: String) -> Bool {
lock.lock()
defer { lock.unlock() }
if let existing = ownership[formatId], existing != pluginId { return false }
ownership[formatId] = pluginId
return true
}

private static func releaseOwnership(pluginId: String, formatId: String) -> Bool {
lock.lock()
defer { lock.unlock() }
guard ownership[formatId] == pluginId else { return false }
ownership.removeValue(forKey: formatId)
return true
}

private static func response(ok: Bool, error: String? = nil) -> String {
var payload: [String: Any] = ["ok": ok]
if let error { payload["error"] = error }
let data = (try? JSONSerialization.data(withJSONObject: payload)) ?? Data()
return String(data: data, encoding: .utf8) ?? "{}"
}
}
Loading
Loading