Zum Inhalt springen

ABI versions and how to migrate

Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.

A plugin declares an abi_version in its plugin.json, and the host loads it if that number is in the range the host supports. This page says what each revision contains and what moving to it costs you.

Current: ABI 4, and ABI 3 still loads. The authoritative numbers are ABI_VERSION and ABI_VERSION_MIN in crates/plugin-core/src/version.rs; if your host refuses your plugin with an ABI mismatch, those are what it compared against. Anything ABOVE the host’s own is refused — a host cannot tell a revision it has never seen from one that changed what an existing field means. Use min_app_version when your plugin needs a newer Aperio, so the user is told to update Aperio instead of shown an ABI error.

Three edits, and they apply to every plugin:

  1. "plugin_type": "adapter" in your plugin.json — the per-surface tags are gone.
  2. "capabilities": [...] naming every family you serve, including "sync" or "videoconference" if that is what you are.
  3. "abi_version": 4, and set struct_size on every vtable you fill in — sizeof the struct. See v3 to v4 below; it is two lines and it is what keeps your plugin loading when Aperio adds a method.

Then point your vtable at an AdapterVtable (section 1 below). Videoconference adapters have one more thing to do, because an existing method changed the shape of its argument — section 2.

One field, and nothing else changed.

Every vtable now carries a struct_size right after vtable_version, in the alignment padding that was already there on 64-bit targets. No slot moved, no vtable grew, and no method was added. Set it to sizeof the struct you are filling in. In Rust the SDK does it for you; in C:

static const AperioCalendarVtable CALENDAR_VTABLE = {
.vtable_version = APERIO_PLUGIN_ABI_VERSION,
.struct_size = sizeof(AperioCalendarVtable),
.list_calendars = my_list_calendars,
/* … */
};

Because it is the field that stops the next revision from breaking you.

Until now, adding one method to a vtable locked out every existing plugin. Not because those plugins were wrong, but because the host could not tell how long their vtable was: a struct built against the shorter layout is indistinguishable from a longer one, and guessing means calling whatever happened to follow it in memory. So the only safe answer was to refuse anything whose version was not exactly the host’s — and your plugin needed a rebuild for a method it does not implement.

With a length, the host copies your bytes into a zeroed struct of its own. A slot you predate arrives as NULL and is reported as unsupported, exactly like a slot you deliberately left out. Your plugin keeps working against Aperio versions released after it.

The other direction is not on offer and cannot be: an Aperio older than your abi_version still refuses your plugin outright, before it looks at a single vtable. It has to — a version number alone cannot tell that revision it never heard of apart from one that changed what an existing slot means. If your plugin needs a newer Aperio, say so in min_app_version; the user then gets “update Aperio” instead of an ABI error.

A plugin still declaring "abi_version": 3 loads, and keeps loading after Aperio appends slots: the host has revision 3’s struct sizes written down and reads exactly that far. Move to 4 anyway — a plugin that states its own size is read at its own size, which is one fewer thing for the host to remember on your behalf, and revision 3 support will not last forever.

AperioPlugin.vtable used to point at a different struct depending on plugin_type: a three-pointer wrapper for a calendar adapter, a bare AperioSyncVtable for a sync adapter, a bare AperioVcVtable for a videoconference one. It now always points at one struct:

typedef struct AperioAdapterVtable {
uint32_t vtable_version;
const AperioCalendarVtable *calendar;
const AperioTasksVtable *tasks;
const AperioContactsVtable *contacts;
const AperioSyncVtable *sync;
const AperioVcVtable *videoconference;
} AperioAdapterVtable;

Fill the families you serve, leave the rest NULL. In Rust:

pub static ADAPTER_VTABLE: AdapterVtable = AdapterVtable {
calendar: &CALENDAR_VTABLE,
..AdapterVtable::empty() // nulls the rest, stamps vtable_version
};

A sync adapter that shipped vtable: SYNC_VTABLE now ships the wrapper with sync: &SYNC_VTABLE; a videoconference adapter the same with videoconference: &VC_VTABLE. The inner vtables did not change.

Two things this buys, and they are why it was worth breaking:

One plugin can serve several families. A provider is not a feature. Google is a calendar, an address book, a task list, a file store to sync into and a meeting service. Under the old shape that was four plugins, four OAuth registrations and four sign-ins to the same account, with four refresh tokens in the keychain that were the same credential. Now it is one library that declares what it does.

The type tag stops being load-bearing for memory safety. It decided which struct the host cast a void* to. Get that wrong — a hand-edited manifest, a copied plugin.json — and the host reads one layout as another and calls whatever function pointer lands at the offset.

Which brings the second half: plugin_type is now "adapter" for every provider surface. "calendar-adapter", "sync-adapter" and "videoconference-adapter" are retired; a manifest still carrying one is listed as unsupported rather than quietly loaded. What your plugin does is its capabilities, which now takes "sync" and "videoconference" alongside "calendar", "tasks" and "contacts".

The host checks that list against your vtable at load time: every capability you declare must have a non-null pointer, or the plugin is refused with a message naming the family. The reverse — a pointer you did not declare — is left alone. An adapter that declares no capability at all is a manifest error, because it would load, register against nothing, and appear installed while doing nothing.

2. delete_meeting takes an object, not a bare id

Section titled “2. delete_meeting takes an object, not a bare id”

The only method whose wire shape changed, and it affects videoconference adapters alone.

It used to receive a JSON string — the meeting id. It now receives

{ "id": "abc123", "notify_attendees": false }

Taking a meeting down is also a question about the people who were invited to it. On a calendar that cannot cancel server-side — a local calendar, a subscribed feed, plain CalDAV — the provider’s own mail is the only word the attendees get that the meeting is off, and the host now says which case it is in.

notify_attendees carries #[serde(default)], so a payload without it decodes as false, i.e. as silence.

Before:

unsafe extern "C" fn ffi_delete_meeting(
h: *mut c_void, a: *const u8, l: usize,
) -> PluginCallResult {
let id: MeetingId = match decode_args(a, l) { Ok(v) => v, Err(r) => return r };
dispatch_unit(h, move |p| async move { p.delete_meeting(&id).await })
}

After:

unsafe extern "C" fn ffi_delete_meeting(
h: *mut c_void, a: *const u8, l: usize,
) -> PluginCallResult {
let removal: MeetingRemoval = match decode_args(a, l) { Ok(v) => v, Err(r) => return r };
dispatch_unit(h, move |p| async move { p.delete_meeting(removal).await })
}

If your provider cannot notify anybody, ignore the flag. Reading removal.id and doing exactly what you did before is a complete migration.

Why this was allowed at all. Changing an existing slot’s wire shape in place is normally forbidden — see the rules below. It was permissible here only because ABI 3 has never shipped, so no plugin exists that speaks the earlier v3 shape. Once v3 is released, the next such change takes v4.

resolve_meeting and list_meetings, appended after delete_meeting:

typedef struct AperioVcVtable {
uint32_t vtable_version;
AperioVtableMethodFn test_connection;
AperioVtableMethodFn create_meeting; /* NewMeeting -> Meeting */
AperioVtableMethodFn get_meeting; /* MeetingId -> Option<Meeting> */
AperioVtableMethodFn delete_meeting; /* {id,notify_attendees} -> () */
/* ── ABI 3 ── */
AperioVtableMethodFn resolve_meeting; /* {join_url} -> Option<Meeting> */
AperioVtableMethodFn list_meetings; /* {start,end} -> Meeting[] */
} AperioVcVtable;

Both may be NULL, and a plugin that leaves them so behaves exactly as it did under v2 — the host simply omits the affordance rather than failing.

resolve_meeting finds a meeting from its join link. The link is the only identifier that reaches a calendar event; your provider’s own meeting id travels nowhere. Without this slot the host can manage only meetings it created itself and still remembers locally — not one made in your web interface, not one made on the user’s other device, not one an invitation brought in.

list_meetings enumerates a window. It is what lets the host surface meetings that have no calendar entry at all.

Appending those two slots is what forced the version bump, and the reason is worth knowing: at v3 the host had no per-vtable length. It read your vtable as a struct of the size IT was compiled with. A plugin built against the shorter layout, loaded by a newer host, would have been read past its end, and strict equality on abi_version was the only thing standing between that and calling whatever memory follows. That is no longer the cost of an append — v3 to v4 added the length, and it is the last append that will ever need a bump.

vtable_version — the u32 at offset 0 of every vtable — is now actually read (vtable_layout_ok), which it was not before v3 despite the header claiming so since the ABI existed. Set it to the ABI version you build against; the SDK’s VcVtable::empty() and friends already do.

None of these break an existing manifest. Omitting them leaves your plugin on exactly the path it was on.

adapter_kind — the short, stable routing key your accounts carry ("caldav", "webex"). Declaring it is what lets the host map an account row back to your plugin without a list of kinds compiled into the core.

It is deliberately not your plugin id: the kind is persisted in every account row and travels in every sync payload, so it has to stay byte-stable for the life of the data, while a plugin id is free to change when a plugin is renamed. You may use your id as your kind; they are simply separate promises.

adopts_adapter_kinds — kinds written by an adapter your plugin has absorbed, so those account rows keep resolving to you instead of reading as “plugin missing”. Resolution only: an adopted kind is never offered as its own entry in the Add-account picker, and your open has to accept the config shape those rows were written with. See the manifest reference.

account — the fields your connect form asks for, which of them are secrets, and whether you sign in via OAuth. Declare it and the host draws your form with no host-side code. See the manifest reference.

strings — your own text, keyed by language then by key, referenced from the label_key / hint_key of an account field. The host resolves against YOUR catalogue, never its own translations, and falls back: requested language, the base of a regional tag (de-AT → de), English, then the verbatim label you wrote. A plugin with no catalogue renders its literals, which is a perfectly good answer.

5. New payload fields, all serde-defaulted

Section titled “5. New payload fields, all serde-defaulted”

NewMeeting gained use_personal_room, attendees and notify_attendees; Meeting gained invitees and join_details. Every one carries #[serde(default)], so a plugin that has never heard of them still deserialises. This is the general rule and it is worth stating outright: adding a serde-default field to a JSON payload is ABI-transparent. Only a brand-new method needs a vtable slot.

One thing to know if you are a videoconference adapter that fills them: NewMeeting::attendees is bare email addresses. The host splits its own display strings ("Alice Smith <alice@example.test>") before handing them over.

aperio_plugin_strings answers one language’s strings, for a plugin whose translations do not fit a JSON block in its manifest. The host calls it once per language and caches the result, merging it over the manifest catalogue.

aperio_plugin_set_host_channel hands your plugin a sink for things the host did not ask about. Vtable calls run host→plugin only, so this is the sole way an adapter can say “the credential I hold has changed” — which an OAuth provider that rotates refresh tokens forces it to say.

Neither is gated on v3, and neither needs any bump: set_host_channel in fact landed while the ABI was still 2. They are listed here because they are new since v2 and you may want them, not because the version number obliges you.

The rules that decide whether the number moves

Section titled “The rules that decide whether the number moves”

These are what the codebase actually follows. If you are extending Aperio rather than writing a plugin, this is the checklist.

Bump required:

  • Changing an existing slot’s argument or return shape, once the current revision has shipped.
  • Any change to a struct’s C layout other than appending — reordering, resizing or removing a field.

No bump:

  • Appending a slot to an existing vtable, including a family pointer on AdapterVtable. This used to be the headline reason for a bump: the host had no per-vtable length, so an older plugin would have been read past its end. struct_size ended that in v4 — an older plugin’s missing slots read as absent, and it keeps loading. That covers ABI 3 plugins too, which carry no size of their own: the host has revision 3’s struct sizes written down, so appending does not move them.
  • A new optional named export, looked up by symbol at load time and absent without consequence: aperio_plugin_interactive_auth, aperio_plugin_discover, aperio_plugin_probe_host_key, aperio_plugin_strings, aperio_plugin_set_log, aperio_plugin_set_host_channel. These are free functions with no instance handle; a host that predates one never looks it up, and a plugin that lacks one is simply asked to do less.
  • Adding a #[serde(default)] field to a JSON payload. A field whose values come from a closed set must be decoded leniently: a host newer than the plugin will send values the plugin’s build has never seen, and one unknown value must not fail the whole payload. Event::keep_fields is the model — a field name the plugin does not know is dropped, so that field is written as it always was.
  • Adding an optional manifest block.

crates/plugin-core/src/version.rs, in the ## History doc comment on ABI_VERSION. This page mirrors it. If the two ever disagree, the Rust source is right and this page is a bug worth reporting.