Back to Super Productivity

Design: Sync Provider Plugins

docs/long-term-plans/sync-provider-plugins.md

18.4.48.7 KB
Original Source

Design: Sync Provider Plugins

Status: Planned

Goal

Enable community developers to build sync providers (Google Drive, OneDrive, S3, etc.) as plugins, using the existing plugin system's runtime loading and sandboxed execution.

Design Decisions

  • Plugins run in the same sandbox as regular plugins (no elevated trust)
  • Plugins handle their own auth UI (OAuth flows, credential forms)
  • Credentials stored via a new persistDataLocal() API (IndexedDB, never synced)
  • App manages encryption — plugins only transport opaque bytes
  • Built-in providers (Dropbox, WebDAV, LocalFile, SuperSync) stay built-in for now
  • Plugin sync providers are always file-based (wrapped by FileBasedSyncAdapterService)

Architecture

1. New Plugin API: registerSyncProvider()

Added to PluginAPI interface. A plugin calls this during initialization:

javascript
plugin.registerSyncProvider({
  id: 'google-drive',
  label: 'Google Drive',
  icon: 'cloud', // material icon name or inline SVG

  // Core file operations
  getFileRev: async (path, localRev) => {
    // Return { rev: string } or throw if not found
  },
  downloadFile: async (path) => {
    // Return { rev: string, dataStr: string }
  },
  uploadFile: async (path, dataStr, revToMatch, isForceOverwrite) => {
    // Return { rev: string }
  },
  removeFile: async (path) => {},

  // State
  isReady: async () => true, // true if configured & authenticated

  // Optional
  listFiles: async (path) => [], // directory listing
  isUploadForcePossible: true, // can force-overwrite on conflict
  maxConcurrentRequests: 4, // concurrent upload/download limit
});

Only one sync provider per plugin. Calling registerSyncProvider() a second time replaces the first.

2. New Plugin API: persistDataLocal() / loadLocalData()

General-purpose local-only storage. Stored in IndexedDB, never synced.

javascript
// Store credentials locally
await plugin.persistDataLocal(
  JSON.stringify({
    accessToken: '...',
    refreshToken: '...',
  }),
);

// Load on startup
const data = await plugin.loadLocalData();
const creds = data ? JSON.parse(data) : null;

Same constraints as persistDataSynced() (1 MB limit, rate limiting), but data stays on-device.

3. PluginSyncProviderAdapter

New class in src/app/plugins/ that wraps plugin callbacks into SyncProviderServiceInterface:

src/app/plugins/plugin-sync-provider-adapter.ts
  • Implements SyncProviderServiceInterface<SyncProviderId>
  • Delegates file operations to plugin callbacks via PluginBridgeService
  • privateCfg uses a no-op credential store (plugin manages its own creds)
  • isReady() delegates to the plugin's isReady() callback

4. SyncProviderManager Changes

File: src/app/op-log/sync-providers/provider-manager.service.ts

Currently: static SYNC_PROVIDERS array populated at construction.

Changes:

  • Add registerPluginProvider(adapter: PluginSyncProviderAdapter) method
  • Add unregisterPluginProvider(providerId: string) method
  • SYNC_PROVIDERS becomes a mutable list (or better: maintain a separate pluginProviders map)
  • SyncProviderId enum extended with a dynamic/string approach for plugin IDs (e.g., plugin:google-drive)
  • activeProviderId$ and related observables react to plugin provider registration

5. Sync Settings UI Changes

File: src/app/features/config/form-cfgs/sync-form.const.ts

Currently: hardcoded provider dropdown options.

Changes:

  • Provider dropdown dynamically includes registered plugin providers
  • When a plugin provider is selected, instead of showing hardcoded form fields:
    • Show a "Configure [Provider Name]" button
    • Clicking it triggers the plugin's config UI (the plugin can use plugin.openDialog(), a side panel, or plugin.showIndexHtmlAsView())
  • Show connection status from the plugin's isReady() result

6. Lifecycle Handling

Startup with plugin sync provider selected:

  1. App starts, loads sync config → selected provider is plugin:google-drive
  2. SyncProviderManager sees unknown provider ID → isProviderReady$ emits false
  3. Plugin system loads and activates the Google Drive plugin
  4. Plugin calls registerSyncProvider(...) → adapter registered with manager
  5. Manager detects matching provider → isProviderReady$ emits true
  6. Sync begins

Plugin disabled/uninstalled:

  1. PluginService calls cleanup → unregisterPluginProvider('plugin:google-drive')
  2. SyncProviderManager removes the provider → isProviderReady$ emits false
  3. Sync stops
  4. Settings UI shows warning: "Sync provider 'Google Drive' unavailable — enable the plugin or select another provider"

Encryption:

  • Managed entirely by the app via FileBasedSyncAdapterService
  • Encrypt key stored in app-level config (existing mechanism)
  • Plugin never sees decrypted data and never handles the key

Files to Modify

FileChange
packages/plugin-api/src/types.tsAdd registerSyncProvider() and persistDataLocal()/loadLocalData() to API types
src/app/plugins/plugin-api.tsImplement new API methods
src/app/plugins/plugin-bridge.service.tsAdd bridge methods for sync provider registration and local data persistence
src/app/plugins/plugin-cleanup.service.tsUnregister sync provider on plugin disable/unload
New: src/app/plugins/plugin-sync-provider-adapter.tsAdapter wrapping plugin callbacks → SyncProviderServiceInterface
src/app/op-log/sync-providers/provider-manager.service.tsAdd registerPluginProvider() / unregisterPluginProvider(), dynamic provider list
src/app/op-log/sync-providers/provider.const.tsSupport dynamic plugin provider IDs alongside the enum
src/app/features/config/form-cfgs/sync-form.const.tsDynamic provider dropdown, "Configure" button for plugin providers
src/app/plugins/store/Add reducer/actions for local plugin data persistence
src/app/plugins/plugin-persistence.model.tsAdd PluginLocalData model

New File

src/app/plugins/plugin-sync-provider-adapter.ts

Thin adapter that implements SyncProviderServiceInterface by delegating to plugin callbacks. ~50-80 lines.

Verification Plan

  1. Unit test: PluginSyncProviderAdapter correctly delegates all methods
  2. Unit test: SyncProviderManager handles dynamic registration/unregistration
  3. Integration test: Plugin registers → appears in settings dropdown → can be selected
  4. E2E test: Build a test sync provider plugin that syncs to a local mock, verify full sync cycle works
  5. Edge case tests: Plugin not loaded at startup, plugin disabled while active, plugin re-enabled

Example Plugin

A minimal Google Drive sync plugin would look like:

javascript
// manifest.json
{
  "name": "Google Drive Sync",
  "id": "google-drive-sync",
  "version": "1.0.0",
  "manifestVersion": 1,
  "minSupVersion": "11.0.0",
  "description": "Sync via Google Drive",
  "hooks": [],
  "permissions": ["syncProvider", "localData"]
}

// plugin.js
const GDRIVE_API = 'https://www.googleapis.com/drive/v3';

let credentials = null;

async function init() {
  const data = await plugin.loadLocalData();
  credentials = data ? JSON.parse(data) : null;
}

plugin.registerSyncProvider({
  id: 'google-drive',
  label: 'Google Drive',
  icon: 'cloud',
  maxConcurrentRequests: 4,
  isUploadForcePossible: true,

  isReady: async () => {
    await init();
    return !!credentials?.accessToken;
  },

  downloadFile: async (path) => {
    // Use fetch() to call Google Drive API
    // Return { rev, dataStr }
  },

  uploadFile: async (path, dataStr, revToMatch, isForceOverwrite) => {
    // Upload to Google Drive
    // Return { rev }
  },

  getFileRev: async (path, localRev) => {
    // Check file metadata on Google Drive
    // Return { rev }
  },

  removeFile: async (path) => {
    // Delete file from Google Drive
  },
});

// Auth UI via menu entry
plugin.registerMenuEntry({
  label: 'Configure Google Drive Sync',
  icon: 'settings',
  onClick: async () => {
    // Show auth dialog, store credentials
    await plugin.persistDataLocal(JSON.stringify(credentials));
  },
});