Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions MAINTAINING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ an **optional** dependency: the plugin works standalone, and lights up host-back
config, account, AI via `requestCompletion`) only when PaperBell is present.

- The shared contract lives at `src/paperbell/shared-config.ts`, a **vendored copy** of PaperBell's
`paperbell-shared-config.ts` (zero-dependency by design).
`paperbell-shared-config.ts` (zero-dependency by design). The host's plugin repo publishes
binaries and docs, not sources, so the thing you actually vendor from is **"附录 A —— 完整契约声明"
in its [README-ZH](https://github.com/PaperBell-Org/Obsidian-PaperBell-Plugin/blob/main/README-ZH.md)**.
Our type names match that appendix verbatim so each re-vendor is a readable diff.
- It is pinned to `PPB_SCHEMA_VERSION`. When PaperBell bumps its schema, **re-vendor** the file and
update the compatibility check.
- ⚠️ **Re-vendoring overwrites our proposal block.** A straight copy from upstream — for *any*
Expand All @@ -54,25 +57,34 @@ config, account, AI via `requestCompletion`) only when PaperBell is present.
as such in the file header and written up in
[docs/PROPOSAL_PROJECTS_SCOPE.md](./docs/PROPOSAL_PROJECTS_SCOPE.md). Its client methods are
declared **optional** and every caller checks `capabilities` *and* `typeof method === "function"`,
so it stays inert against every host that exists today. `PPB_SCHEMA_VERSION` stays at `1` while it
is a proposal — bumping it unilaterally would silence the newer-schema warning for a real upstream
v2. When the host ships it, re-vendor as usual and delete the proposal marker.
so it stays inert against every host that exists today. `PPB_SCHEMA_VERSION` tracks the **host's**
number and nothing else — never bump it for a proposal, or the newer-schema warning goes quiet for
a real upstream bump. When the host ships it, re-vendor as usual and delete the proposal marker.

### Contract conformance (verified against PaperBell 0.4.4)
### Contract conformance (verified against PaperBell 0.4.7)

The real host's `install()` does exactly what our client assumes:
`this.plugin.api = api` (so `app.plugins.plugins["paperbell"].api` works), `window.registerPPBplugin = api.registerPPBplugin`, then `workspace.trigger("paperbell:ready", api)`. Its `getPluginInfo()`
returns `schemaVersion: 1` and
returns `schemaVersion: 2` and
`capabilities: ["account","config","plugin-info","llm-invoke","llm-credentials","activation","download-ticket"]`
— matching our vendored `PPB_SCHEMA_VERSION` and feature gating. We depend ONLY on this handshake
contract, never on PaperBell's main features (which change independently).

The host has since grown three backward-compatible scopes/methods on top of the original four —
`requestLLMCredentials` (`llm-credentials`), `requestActivationInfo` (`activation`), and
`requestProtectedDownloadTicket` (`download-ticket`) — plus the `providerId` / `providerName` /
`hasApiKey` fields on the public LLM config and a host-internal `paperbell:plugins-changed` event.
`schemaVersion` stayed `1` (additions only), so no compatibility break; the vendored contract and the
thin `PaperBellClient` wrappers are synced to this surface.
What 0.4.7 changed, and how we answer it (upstream's own
[MIGRATION-0.4.7.md](https://github.com/PaperBell-Org/Obsidian-PaperBell-Plugin/blob/main/MIGRATION-0.4.7.md)):

| Host change | Us |
| --- | --- |
| `app.plugins.plugins["paperbell"]` now exposes only `api` + `settings`; every internal object is private | Nothing to do — we only ever read `.api`, and never `plugin.settings` |
| `settings.pluginGrants` is no longer a writable array (it was a real privilege-escalation hole) | Nothing to do — we never forged grants; consent has always been the normal flow |
| A handle from a **previous** host load goes inert: every `request*` returns `null`, no throw | **Fixed here.** `client.ts` re-handshakes on every `paperbell:ready` instead of guarding against a second connect |
| `llm.baseUrl` / `llm.model` are now *effective* values (no trailing slash, defaults filled in) | Nothing to do — no feature builds a URL from them yet. When one does, still strip trailing slashes defensively |
| Only plugins **holding at least one scope** get a card in PaperBell's settings | Accepted deliberately — see "Deferred consent" in [docs/PAPERBELL_INTEGRATION.md](./docs/PAPERBELL_INTEGRATION.md) |

Schema `1` → `2` narrowed the *broadcast* payload (`paperbell:config-changed`) to a public
language/profile layer; the per-client `onConfigChange` push we actually consume still carries the
restricted config, so the vendored types and the thin `PaperBellClient` wrappers stay as they were,
plus the optional `profile` / `cimpoFolders` / completion-quota fields 0.4.7 added.

### Verifying the handshake live

Expand All @@ -89,6 +101,10 @@ needed. To verify against the **real** host in Obsidian:
4. Check: our settings tab shows a "PaperBell" section reading *Connected*; the console logs
`[PaperOut] Connected to PaperBell host.`; the "Connect/Refresh" button pulls account/config
(PaperBell prompts for consent the first time); disabling our plugin calls `unregister()`.
5. **Reload recovery** (the 0.4.7 case): with both plugins running, disable and re-enable *PaperBell*
only. The console must log `[PaperOut] Reconnected to PaperBell host after it reloaded.`, our
settings section must still read *Connected*, and switching PaperBell's language must still
re-render our UI — all without touching our plugin.

An **automated, decoupled** guard also runs in the test suite: `test/paperbell/host-conformance.test.ts`
statically checks that any bundle present at that path still exposes the handshake surface (events,
Expand Down
68 changes: 55 additions & 13 deletions docs/PAPERBELL_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,45 @@ safe. The contract itself is a **vendored copy** of PaperBell's IPC surface; see

## The handshake

The host plugin (`app.plugins.plugins["paperbell"]`) exposes an `api` object and, on load,
fires a one-shot `paperbell:ready` workspace event carrying that API. Load order between
the two plugins is unspecified, so our client (`src/paperbell/client.ts`) covers both cases:
The host plugin (`app.plugins.plugins["paperbell"]`) exposes an `api` object and, on **every**
load, fires a `paperbell:ready` workspace event carrying that API. Load order between the two
plugins is unspecified, so our client (`src/paperbell/client.ts`) covers both cases:

1. **Probe** for `app.plugins.plugins["paperbell"].api` immediately, and
2. **Listen once** for `paperbell:ready` in case the host loads after us.
2. **Stay subscribed** to `paperbell:ready` for as long as we are loaded.

Whichever fires first wins; a guard prevents double-registration. Registration calls
`host.registerPPBplugin({ id, name, description, icon, onOpen })`. Our `onOpen` deep-links
back to this plugin's own settings tab, so PaperBell's settings can show a "PaperOut" entry
card that opens our settings. On plugin unload we call `unregister()` and reset our store.
Registration calls `host.registerPPBplugin({ id, name, description, icon, onOpen })`. Our
`onOpen` deep-links back to this plugin's own settings tab, so PaperBell's settings can show a
"PaperOut" entry card that opens our settings. On plugin unload we call `unregister()` and
reset our store.

### Why the listener is permanent

The handle `registerPPBplugin()` returns is bound to *that* load of the host. When PaperBell
updates — or is disabled and re-enabled — the handle survives as an object but goes inert:
since host 0.4.7 every `request*` on it resolves to `null` (deliberately, instead of throwing
something cryptic) and its `onConfigChange` push is gone.

So `attach()` runs on each ready event, not just the first: it releases the old handle
(`unsubscribe` + `unregister`, both wrapped — they reach into a host that may already be gone),
registers again, re-reads capabilities, and re-subscribes. Two details make the recovery
invisible to the user:

- the last known config is **kept** — across the reconnect, and even if re-registering fails —
so the UI doesn't flip back to the fallback language for the split second before a fresh one
arrives. Only unloading our plugin clears it; and
- **on a reconnect only**, if `listGrants()` says the user already granted `config`, we refetch
it. The grant outlived the reload, so that costs no consent prompt, and it is how we notice a
language the host changed while our handle was dead. A first connect stays scope-free — see
*Deferred consent* under **Scopes** below.

Note there is no "same host object, skip the handshake" shortcut. Whether a reloaded host hands
back a fresh `api` is its business, and guessing wrong would leave us on a dead handle forever —
the exact bug this replaced. A redundant re-register costs one `unregister()` and one
`registerPPBplugin()`; we never hold two at once.

Guarding against a "double connect" here instead — the shape this code had before host 0.4.7 —
is what makes a plugin silently stop following the host after the first PaperBell update.

If no host is found, the client simply stays disconnected and every method below returns
`null` — the rest of the plugin never notices.
Expand All @@ -42,10 +70,19 @@ the user the first time it touches a scope; approval is remembered, denial retur
| `download-ticket` | a ticket for a protected download | Wired, not yet used by a feature |
| `projects` | the host's project list, for linking an output to its project | **Proposed** — consumed by the new-paper modal, but no shipped host implements it |

### Deferred consent (and what it costs)

We deliberately request **no** scopes at startup — that would trigger a consent prompt on
every launch. Only `plugin-info` (which needs no consent) is read eagerly to learn the
host's `capabilities`; everything else is requested lazily on an explicit user action.

Since host 0.4.7 a plugin's card only appears in PaperBell's own settings page once it
**holds at least one scope** — registering is no longer enough. Combined with the above,
that means a user who has never pressed **Connect / Refresh** (and never used a host-backed
feature) won't see PaperOut listed there. We accept that: a consent dialog on every launch is
a worse trade than a card that appears the moment the integration is actually used. Once any
scope is granted the card shows up and stays, reload after reload.

## What the integration does today

### Follow the host's language
Expand Down Expand Up @@ -120,7 +157,8 @@ host team in [PROPOSAL_PROJECTS_SCOPE.md](./PROPOSAL_PROJECTS_SCOPE.md).
- No host → client stays disconnected; `connected` is `false`, `config` is `null`,
`capabilities` is `[]`.
- Every `fetch*` / `request*` helper on `PaperBellClient` returns `null` when disconnected,
so callers can treat "host absent" and "scope denied" identically.
so callers can treat "host absent" and "scope denied" identically — and, for the window
between a host reload and the ready event that heals it, "host restarting" too.
- Language falls back to Obsidian's UI language; account UI shows "not connected".
- Nothing about compiling, scaffolding, or Pandoc export depends on the host.

Expand All @@ -132,8 +170,12 @@ schema version than we vendored, the client logs a warning (it does not break).
host bumps its schema, re-vendor this file and reconcile the check — the procedure and a
decoupled conformance test are described in [MAINTAINING.md](../MAINTAINING.md).

It currently tracks host **0.4.7** (`PPB_SCHEMA_VERSION = 2`). The v1 → v2 bump narrowed the
*broadcast* `paperbell:config-changed` payload to a public language/profile layer; the directed
`onConfigChange` push we consume still carries the restricted config, so nothing we read moved.

One block of that file is **ours, not upstream's**: the proposed `projects` scope, flagged
in the file header. `PPB_SCHEMA_VERSION` stays at `1` while it is a proposal — raising it
unilaterally would silence the "host schema is newer than vendored" warning for a real
upstream v2. Feature detection never reads the schema version anyway; it reads
capabilities and checks the method exists.
in the file header. `PPB_SCHEMA_VERSION` tracks the host's number and nothing else — raising
it for a proposal would silence the "host schema is newer than vendored" warning for a real
upstream bump. Feature detection never reads the schema version anyway; it reads capabilities
and checks the method exists.
Loading