Manifest and setup
The manifest is how the app learns about a plugin without knowing anything about the system behind it. It is plain data.
import type { IntegrationType } from './domain'
export interface IntegrationCapabilities { containers: boolean items: boolean search: boolean images: boolean deepLinks: boolean incrementalSync: boolean remoteQrCodes: boolean quantities: boolean}
/** One input in a plugin's setup form. The core UI renders these without knowing the provider. */export interface SetupField { key: string label: string type: 'text' | 'url' | 'password' required?: boolean placeholder?: string help?: string}
/** A way of authenticating. Its `method` becomes the discriminant of the credentials object. */export interface CredentialMethodSpec { method: string label: string fields: SetupField[]}
export interface IntegrationSetup { /** Non-secret instance configuration (e.g. base URL); stored in the local database. */ config: SetupField[] /** Credentials are stored in secure storage, never in the database. */ credentials: CredentialMethodSpec[]}
export interface IntegrationManifest { id: IntegrationType name: string version: string description?: string icon?: string minimumCoreVersion?: string capabilities: IntegrationCapabilities
setup: IntegrationSetup}id and version
Section titled “id and version”id identifies the plugin type and must be unique among registered plugins (the registry throws on a duplicate). Use a
reverse-domain name you control, for example com.example.shelfie. Do not change it once sources exist: stored sources refer to it.
Capabilities
Section titled “Capabilities”Capabilities tell the app which features to offer. Declare only what the plugin really does.
| Capability | Meaning |
|---|---|
containers, items |
The plugin lists containers and the items in them. |
search |
The runtime implements search. |
images |
Items or containers carry imageUrl. |
deepLinks |
The runtime implements getDeepLink, so UniBox can open the source’s own page. |
incrementalSync |
sync({ since }) can return only what changed. |
remoteQrCodes |
The source has its own QR/label system worth surfacing. |
quantities |
Items carry quantity. |
The setup form
Section titled “The setup form”setup.config describes non-secret settings (such as the server address). They are stored in the app’s local database.
setup.credentials lists the ways of signing in. Each entry has a method name and its fields. Secrets are kept in the
device’s secure storage, never in the database.
// Non-secret settings: stored in the local database.config: [{ key: 'baseUrl', label: 'Server address', type: 'url', placeholder: 'https://shelfie.example' }],The app renders these fields, validates them with the SDK’s validateSetup, and passes the cleaned values to your plugin:
config to create, and the chosen method’s fields to connect. The credentials object’s method is the discriminant, so
a plugin with several sign-in methods can switch on it.
- Fields are required unless
required: false. - A
urlfield must be anhttporhttpsaddress. - Only declared fields reach the plugin.
import type { IntegrationSetup, SetupField } from './manifest'
export interface SetupValues { config: Record<string, string> /** `method` selects one of `setup.credentials`; the remaining keys are that method's fields. */ credentials: { method: string } & Record<string, string>}
export interface SetupIssue { scope: 'config' | 'credentials' key: string message: string}
function checkField(scope: SetupIssue['scope'], field: SetupField, value: string | undefined, out: SetupIssue[]) { const v = value?.trim() ?? '' if (!v) { if (field.required !== false) out.push({ scope, key: field.key, message: `${field.label} is required` }) return } if (field.type === 'url') { try { const u = new URL(v) if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('protocol') } catch { out.push({ scope, key: field.key, message: `${field.label} must be an http(s) URL` }) } }}
/** Validate form values against a plugin's setup schema. Fields are required unless `required: false`. */export function validateSetup(setup: IntegrationSetup, values: SetupValues): SetupIssue[] { const issues: SetupIssue[] = [] for (const f of setup.config) checkField('config', f, values.config[f.key], issues) const method = setup.credentials.find(m => m.method === values.credentials.method) if (!method) { issues.push({ scope: 'credentials', key: 'method', message: 'Choose how to sign in' }) } else { for (const f of method.fields) checkField('credentials', f, values.credentials[f.key], issues) } return issues}
/** Trim form strings into the plain objects handed to the plugin. Only declared fields are kept. */export function buildPluginInputs(setup: IntegrationSetup, values: SetupValues) { const config: Record<string, string> = {} for (const f of setup.config) config[f.key] = (values.config[f.key] ?? '').trim() const spec = setup.credentials.find(m => m.method === values.credentials.method) const credentials: Record<string, string> = { method: values.credentials.method } for (const f of spec?.fields ?? []) credentials[f.key] = values.credentials[f.key] ?? '' return { config, credentials }}