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.
If you only do one thing
Section titled “If you only do one thing”Three edits, and they apply to every plugin:
"plugin_type": "adapter"in yourplugin.json— the per-surface tags are gone."capabilities": [...]naming every family you serve, including"sync"or"videoconference"if that is what you are."abi_version": 4, and setstruct_sizeon every vtable you fill in —sizeofthe 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.
v3 → v4
Section titled “v3 → v4”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, /* … */};Why it is worth two lines
Section titled “Why it is worth two lines”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.
v2 → v3
Section titled “v2 → v3”1. One vtable for every plugin
Section titled “1. One vtable for every plugin”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.
3. VcVtable gained two slots
Section titled “3. VcVtable gained two slots”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.
4. New manifest blocks, all optional
Section titled “4. New manifest blocks, all optional”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.
6. Two new optional named exports
Section titled “6. Two new optional named exports”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_sizeended 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_fieldsis 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.
Where the authoritative list lives
Section titled “Where the authoritative list lives”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.