Skip to content
UniBox

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.

@unibox-cam/integration-sdk · manifest.ts
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 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 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.

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.

a config field
// 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 url field must be an http or https address.
  • Only declared fields reach the plugin.
@unibox-cam/integration-sdk · setup.ts
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 }
}