Plugin Development Guide

Build capabilities for new file types

Overview

Plugins are standalone executables that add capabilities to FileGrind. A plugin can handle one file type (like PDFs) or many. It declares what it can do via a manifest, and FileGrind calls it when those capabilities are needed.

What a plugin does

  • Responds to manifest command with its capabilities
  • Implements one or more capability commands (extract-metadata, grind, etc.)
  • Reads input from command-line arguments or stdin
  • Writes JSON output to stdout

Languages

Plugins can be written in any language that produces a native executable. We provide SDKs for:

  • Rust - fgnd-plugin-sdk crate
  • Go - fgnd-plugin-sdk-go module
  • Swift/Objective-C - FGNDPluginSDK framework

The SDK is optional—you can write a plugin in Python, C++, or anything else as long as it follows the protocol.

Architecture

Plugins run in a sandboxed XPC service, separate from the main app. FileGrind never loads plugin code directly.

Execution flow

  1. FileGrind's XPC service discovers plugins at startup
  2. For each executable, it runs plugin manifest
  3. Parses the JSON manifest to learn what capabilities exist
  4. Registers capabilities with the router
  5. When a capability is needed, spawns the plugin with arguments
  6. Plugin writes output to stdout, errors to stderr
  7. XPC service captures output and returns it to the app

Plugin locations

The XPC service looks for plugins in two places:

  • FileGrind.app/Contents/Resources/pluginsrv-signed/plugins/ - Bundled
  • /Library/Application Support/FileGrind/Plugins/ - User-installed

Security requirements

Plugins distributed via FileGrind must be:

  • Code-signed with a Developer ID certificate
  • Notarized by Apple
  • Packaged as a .pkg installer

For development, you can test unsigned plugins by placing them in the plugins directory and allowing them in System Settings.

Plugin Manifest

When called with manifest as the first argument, your plugin must print a JSON object describing itself.

Manifest structure

{
  "name": "myplugin",
  "version": "1.0.0",
  "description": "Processes XYZ files",
  "authors": ["Your Name"],
  "caps": [
    {
      "urn": "cap:op=extract;format=xyz;target=metadata",
      "title": "Extract XYZ Metadata",
      "description": "Extract metadata from XYZ files",
      "command": "extract-metadata",
      "arguments": {
        "required": [...],
        "optional": [...]
      },
      "output": {...},
      "accepts_stdin": true
    }
  ]
}

Fields

name Plugin identifier (lowercase, no spaces)
version Semantic version (e.g., "1.0.0")
description What the plugin does
authors List of author names (optional)
caps Array of capability definitions

Example: Rust implementation

use clap::{Parser, Subcommand};

#[derive(Parser)]
struct Args {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Manifest,
    ExtractMetadata { file: String },
}

fn main() {
    let args = Args::parse();
    match args.command {
        Commands::Manifest => {
            let manifest = get_plugin_manifest();
            println!("{}", serde_json::to_string_pretty(&manifest).unwrap());
        }
        Commands::ExtractMetadata { file } => {
            // ... implementation
        }
    }
}

Declaring Capabilities

Each capability in your manifest describes one operation. Capabilities use CAPNS URNs for identification.

Standard capabilities

FileGrind recognizes these standard operations:

extract-metadata Extract file metadata (title, author, page count, etc.)
extract-outline Extract table of contents / document structure
grind Extract text content by page or section
generate-thumbnail Generate preview image

Capability URN format

URNs are built from tags that describe the operation:

cap:op=extract;format=pdf;target=metadata
cap:op=extract;format=epub;target=pages
cap:op=generate;format=pdf;target=thumbnail

Capability definition

{
  "urn": "cap:op=extract;format=xyz;target=metadata",
  "title": "Extract XYZ Metadata",
  "description": "Extract metadata from XYZ files",
  "command": "extract-metadata",
  "metadata": {
    "file_types": "xyz,abc"
  },
  "arguments": {
    "required": [
      {
        "name": "file_path",
        "media_spec": "std:str.v1",
        "arg_description": "Path to input file",
        "cli_flag": "file_path",
        "position": 0
      }
    ],
    "optional": [
      {
        "name": "output",
        "media_spec": "std:str.v1",
        "arg_description": "Output file path",
        "cli_flag": "--output"
      }
    ]
  },
  "output": {
    "media_spec": "std:obj.v1",
    "output_description": "Extracted metadata as JSON"
  },
  "accepts_stdin": false
}

Argument types (media_spec)

  • std:str.v1 - String
  • std:int.v1 - Integer
  • std:num.v1 - Number (float)
  • std:bool.v1 - Boolean
  • std:obj.v1 - JSON object
  • std:binary.v1 - Binary data

Implementing Commands

Each capability maps to a command that your plugin handles. The command field in the capability definition tells FileGrind how to invoke your plugin.

Command invocation

FileGrind calls your plugin like this:

# Extract metadata
./myplugin extract-metadata /path/to/file.xyz

# With optional output file
./myplugin extract-metadata /path/to/file.xyz --output metadata.json

# Generate thumbnail
./myplugin generate-thumbnail /path/to/file.xyz --width 256 --height 256

Example: extract-metadata

fn extract_metadata(file_path: &str) -> Result<FileMetadata> {
    // 1. Validate file exists and is readable
    let path = Path::new(file_path);
    if !path.exists() {
        anyhow::bail!("File not found: {}", file_path);
    }

    // 2. Read and parse the file
    let content = fs::read(file_path)?;
    let parsed = parse_xyz_format(&content)?;

    // 3. Build metadata structure
    let mut metadata = FileMetadata::new(
        file_path.to_string(),
        "xyz".to_string(),
        content.len() as u64,
    );
    metadata.title = parsed.title;
    metadata.authors = parsed.authors;
    metadata.page_count = Some(parsed.pages.len());

    Ok(metadata)
}

fn main() {
    match args.command {
        Commands::ExtractMetadata { file, output } => {
            match extract_metadata(&file) {
                Ok(metadata) => {
                    let json = serde_json::to_string_pretty(&metadata).unwrap();
                    if let Some(out_path) = output {
                        fs::write(out_path, &json).unwrap();
                    } else {
                        println!("{}", json);
                    }
                }
                Err(e) => {
                    eprintln!("Error: {}", e);
                    std::process::exit(1);
                }
            }
        }
    }
}

Example: grind

fn grind(file_path: &str, page_range: Option<&str>) -> Result<GroundChips> {
    let parsed = parse_xyz_file(file_path)?;
    let pages_to_extract = parse_page_range(page_range, parsed.pages.len())?;

    let mut doc_pages = GroundChips::new(file_path, "xyz");

    for page_num in pages_to_extract {
        let page_content = &parsed.pages[page_num];
        let doc_page = FileChip::new_with_text(
            page_num + 1,  // 1-indexed
            page_content.text.clone(),
        );
        doc_pages.add_chip(doc_page);
    }

    Ok(doc_pages)
}

Input/Output

Input

Plugins receive input through:

  • Command-line arguments - File paths, flags, options
  • Stdin - If accepts_stdin: true, can receive file content

Output

Plugins write output to:

  • Stdout - JSON data or binary content
  • Stderr - Progress messages, warnings, errors
  • File - If --output flag is provided

Exit codes

  • 0 - Success
  • 1 - General error
  • 2 - Invalid arguments

Output formats

FileMetadata output

{
  "file_path": "/path/to/file.xyz",
  "file_size_bytes": 1048576,
  "document_type": "xyz",
  "title": "Document Title",
  "authors": ["Author Name"],
  "page_count": 42,
  "creation_date": "2024-01-10T15:30:00Z",
  "keywords": ["tag1", "tag2"],
  "extended_metadata": {
    "custom_field": "value"
  }
}

DocumentOutline output

{
  "source_file": "/path/to/file.xyz",
  "document_type": "xyz",
  "total_pages": 42,
  "has_outline": true,
  "entries": [
    {
      "title": "Chapter 1",
      "level": 0,
      "page": 1,
      "children": [
        {
          "title": "Section 1.1",
          "level": 1,
          "page": 5,
          "children": []
        }
      ]
    }
  ],
  "extraction_info": {
    "extractor_name": "myplugin",
    "extractor_version": "1.0.0"
  }
}

GroundChips output

{
  "source_file": "/path/to/file.xyz",
  "document_type": "xyz",
  "total_pages": 42,
  "pages": [
    {
      "order_index": 1,
      "text_content": "Content of page 1...",
      "word_count": 250,
      "character_count": 1500
    },
    {
      "order_index": 2,
      "text_content": "Content of page 2...",
      "word_count": 300,
      "character_count": 1800
    }
  ]
}

Using the SDK

The SDK provides standard data structures and helpers. Using it ensures your output matches what FileGrind expects.

Rust SDK

# Cargo.toml
[dependencies]
fgnd-plugin-sdk = { git = "https://github.com/fgnd/fgnd-plugin-sdk" }
capns = { git = "https://github.com/fgnd/capns" }
clap = { version = "4.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
use fgnd_plugin_sdk::{
    FileMetadata, DocumentOutline, GroundChips,
    OutlineEntry, FileChip, ExtractionInfo,
};

// Create metadata
let mut metadata = FileMetadata::new(path, "xyz", file_size);
metadata.title = Some("Title".into());
metadata.add_author("Author");

// Create outline
let mut outline = DocumentOutline::new(path, "xyz", page_count);
let entry = OutlineEntry::new("Chapter 1", 0).with_page(1);
outline.add_entry(entry);

// Create pages
let mut pages = GroundChips::new(path, "xyz");
let page = FileChip::new_with_text(1, "Content...");
pages.add_chip(page);

Go SDK

import sdk "github.com/fgnd/fgnd-plugin-sdk-go"

// Create metadata
metadata := sdk.NewFileMetadata(path, "xyz", fileSize)
metadata.Title = "Title"
metadata.AddAuthor("Author")

// Create outline
outline := sdk.NewDocumentOutline(path, "xyz", pageCount)
entry := sdk.NewOutlineEntry("Chapter 1", 0).WithPage(1)
outline.AddEntry(entry)

// Create pages
pages := sdk.NewGroundChips(path, "xyz")
page := sdk.NewFileChipWithText(1, "Content...")
pages.AddPage(page)

SDK data structures

See SDK Reference for complete documentation.

Testing

Manual testing

# Test manifest
./myplugin manifest | jq .

# Test extract-metadata
./myplugin extract-metadata test-file.xyz | jq .

# Test with output file
./myplugin extract-metadata test-file.xyz -o output.json
cat output.json | jq .

Validate manifest

The SDK includes a validator tool:

plugin-validator ./myplugin

Test with FileGrind

  1. Build your plugin
  2. Copy to /Library/Application Support/FileGrind/Plugins/
  3. Restart FileGrind
  4. Add a file of your supported type
  5. Check that grinding works

Packaging

Plugins are distributed as .pkg installers. This handles macOS security requirements (Gatekeeper, quarantine).

Build requirements

  • Universal binary (arm64 + x86_64) or Apple Silicon only
  • Signed with Developer ID certificate
  • Notarized by Apple

Creating a .pkg

# Build universal binary (Rust example)
cargo build --release --target aarch64-apple-darwin
cargo build --release --target x86_64-apple-darwin
lipo -create -output myplugin \
  target/aarch64-apple-darwin/release/myplugin \
  target/x86_64-apple-darwin/release/myplugin

# Sign the binary
codesign --sign "Developer ID Application: Your Name (TEAMID)" \
  --options runtime \
  --timestamp \
  myplugin

# Create installer package
pkgbuild --root ./pkg-root \
  --identifier com.yourcompany.myplugin \
  --version 1.0.0 \
  --install-location "/Library/Application Support/FileGrind/Plugins" \
  myplugin.pkg

# Sign the package
productsign --sign "Developer ID Installer: Your Name (TEAMID)" \
  myplugin.pkg \
  myplugin-signed.pkg

# Notarize
xcrun notarytool submit myplugin-signed.pkg \
  --apple-id your@email.com \
  --team-id TEAMID \
  --password "@keychain:AC_PASSWORD" \
  --wait

# Staple
xcrun stapler staple myplugin-signed.pkg

Publishing

To make your plugin available through FileGrind's plugin browser:

Requirements

  • Plugin source code in a public GitHub repository
  • Signed and notarized .pkg installer
  • README with description and usage

Submit for inclusion

Email plugins@filegrind.com with:

  • GitHub repository URL
  • Link to downloadable .pkg file
  • Brief description of what file types it handles

We review submissions and add approved plugins to the registry. Future versions will automate this process.

Plugin registry format

The registry is a JSON file listing available plugins:

{
  "plugins": [
    {
      "name": "myplugin",
      "version": "1.0.0",
      "description": "Processes XYZ files",
      "download_url": "https://github.com/.../releases/download/v1.0.0/myplugin.pkg",
      "sha256": "abc123...",
      "file_types": ["xyz", "abc"],
      "repository": "https://github.com/yourname/myplugin"
    }
  ]
}