From bd42511bbc0e5e2ac7f2e7eb9b282cedd1169a06 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 07:29:02 -0700 Subject: [PATCH 1/3] contract: pin the mirror to Lithe ce175e0 (plugin backend and function protocol 2) Pins the commit of 1lck/Lithe-IDEA#1470 that adds the backend capability, plugin-function protocol 2 with the credentials host service, and lists the Rust SDK. Re-pin to preview once that pull request merges. Co-Authored-By: Claude Opus 5.5 --- contract.lock.json | 54 +- contract/v1/README.md | 430 +++++++++- contract/v1/fixtures/backend.plugin.json | 49 ++ contract/v1/fixtures/function-protocol.json | 91 +- contract/v1/fixtures/invalid-manifests.json | 54 ++ .../v1/fixtures/native-controls.plugin.json | 407 +++++++++ contract/v1/fixtures/native-form.plugin.json | 157 ++++ contract/v1/fixtures/native-panel.plugin.json | 137 +++ .../.gitattributes | 2 + .../project-generator-templates/app/README.md | 3 + .../app/dot-gitignore | 0 .../main/Main.java | 3 + .../v1/fixtures/project-generator.plugin.json | 83 ++ contract/v1/fixtures/project-plans.json | 360 ++++++++ contract/v1/native-ui.schema.json | 781 ++++++++++++++++++ contract/v1/plugin-manifest.schema.json | 196 ++++- 16 files changed, 2759 insertions(+), 48 deletions(-) create mode 100644 contract/v1/fixtures/backend.plugin.json create mode 100644 contract/v1/fixtures/native-controls.plugin.json create mode 100644 contract/v1/fixtures/native-form.plugin.json create mode 100644 contract/v1/fixtures/native-panel.plugin.json create mode 100644 contract/v1/fixtures/project-generator-templates/.gitattributes create mode 100644 contract/v1/fixtures/project-generator-templates/app/README.md create mode 100644 contract/v1/fixtures/project-generator-templates/app/dot-gitignore create mode 100644 contract/v1/fixtures/project-generator-templates/main/Main.java create mode 100644 contract/v1/fixtures/project-generator.plugin.json create mode 100644 contract/v1/fixtures/project-plans.json create mode 100644 contract/v1/native-ui.schema.json diff --git a/contract.lock.json b/contract.lock.json index fabf8b6..e10f0ff 100644 --- a/contract.lock.json +++ b/contract.lock.json @@ -3,12 +3,16 @@ "source": { "repository": "https://github.com/1lck/Lithe-IDEA", "directory": "shared/contracts/plugins/v1", - "revision": "18345cc96a89e1af3b04b48aacd9af89350202cb" + "revision": "ce175e0ebb1a21f1876cf28b42fb8e47725a4b76" }, "files": [ { "path": "README.md", - "sha256": "21fc28bb9c7299959adbe0223c348ff0b1f0764f16fa87e21bbebf50256a49e2" + "sha256": "cd73946b46c93875a9fb5015be95a733b2ad7a6d2efbf4077be473609ba87251" + }, + { + "path": "fixtures/backend.plugin.json", + "sha256": "c83d455cd1981407e903f28b6bfdb38309715856499feba5d76af85a097e1977" }, { "path": "fixtures/build-command-output-file.plugin.json", @@ -48,7 +52,7 @@ }, { "path": "fixtures/function-protocol.json", - "sha256": "278a56b3705be4187ec8ed8fc1ed4b788f019f375c71e87f7f50701fb3cdbea0" + "sha256": "980e8d39db2af9d7f922cdc01b0ec06e2cb8c33da2142ec8040befa975b7eae5" }, { "path": "fixtures/hello-lithe.plugin.json", @@ -56,7 +60,7 @@ }, { "path": "fixtures/invalid-manifests.json", - "sha256": "ae5e5021d1f62fbf4f5ca0a8529a27ca045810346e16751644f6651dff00bdd4" + "sha256": "107afd7d3f440a87aab3611a80aa5e92c067cd719019dafd94bda8cdbd6b842f" }, { "path": "fixtures/language-host-integration.plugin.json", @@ -90,10 +94,46 @@ "path": "fixtures/marketplace/source-urls.json", "sha256": "88a1b00fc443a65ca5faa8ac519e93de4e6abca8297abb9045613317747f41b5" }, + { + "path": "fixtures/native-controls.plugin.json", + "sha256": "fc160a33d801d44222d20d6c79b1726e8e9dceb929fa99695a7990b3f4cb57cb" + }, + { + "path": "fixtures/native-form.plugin.json", + "sha256": "082dd621eadad410a0aadc08e35e213e6818853ae4c18a2d38ce7c6f5bd8946d" + }, + { + "path": "fixtures/native-panel.plugin.json", + "sha256": "b35c69c85f4e53f4ccd31e320e2f91387dde1935e10bde5b13019fd6e925fe1d" + }, { "path": "fixtures/node-runtime.plugin.json", "sha256": "3cbc80303dca796302653982d3f55fdc2072aea69a2517286a06e73fbc3c4499" }, + { + "path": "fixtures/project-generator-templates/.gitattributes", + "sha256": "5ab88980b2cd4db0455bb2d5c3703db6668d28f254c41a92a6feff64604af9f7" + }, + { + "path": "fixtures/project-generator-templates/app/README.md", + "sha256": "c02eaa65ce4ba3a06c92c8fc5825508347437a5f3a2b1870e11f6510947e4fb7" + }, + { + "path": "fixtures/project-generator-templates/app/dot-gitignore", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "fixtures/project-generator-templates/main/Main.java", + "sha256": "5435c9be433e0a1cadbeccc701451b21d53d8519e1516c58960af944fe0bf616" + }, + { + "path": "fixtures/project-generator.plugin.json", + "sha256": "1e5598cfe85e62711c8f5e4090dc229df732380b75d519a0e95f7a8345740c5b" + }, + { + "path": "fixtures/project-plans.json", + "sha256": "14c6fa8b073732f93b85b30e4fa9e8384ce094f2fb6fe833e41476f12c8d2132" + }, { "path": "fixtures/runtime-versions.json", "sha256": "a19c1c057ab54a2b24a4a0e5a0d7764f0b9063bb6dd356baf19f5f6e37a76e19" @@ -110,13 +150,17 @@ "path": "fixtures/web-panel.plugin.json", "sha256": "1ccaf5bb58e0296b8d244e6951d62c1f8e9e35e2e2bd467d4368fc31d7f42fd8" }, + { + "path": "native-ui.schema.json", + "sha256": "62c6e49ec274c52efb70cc3562f261e11d4dfb8cc59c8e13f7774321266f2837" + }, { "path": "plugin-index.schema.json", "sha256": "6771cb0e8c1ba101e6fc90b436de1b74f057ff0d58cc90f92e669edc9a102fc6" }, { "path": "plugin-manifest.schema.json", - "sha256": "27b01d48b99377eeaf473b1b670c4c19dbf86df81c6623205401226d13d351c8" + "sha256": "a70921c16f1d45e342777fc6f8fe9fc91efa82427d377461d356b8f00054008f" } ] } diff --git a/contract/v1/README.md b/contract/v1/README.md index 5405eb9..92bb546 100644 --- a/contract/v1/README.md +++ b/contract/v1/README.md @@ -17,6 +17,8 @@ This directory is the cross-platform source of truth: | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | | `fixtures/build-plans.json` | Golden build-system plans and decoded outputs | | `fixtures/function-protocol.json` | Golden plugin-function negotiation, registration, invocation, outcome, progress, and refusal cases | +| `fixtures/project-generator.plugin.json`, `fixtures/project-generator-templates/` | Project generators: template files, a scaffolding command, and an archive download | +| `fixtures/project-plans.json` | Golden `project.*` form evaluations, generation plans, and name checks | | `plugin-index.schema.json` | JSON shape of a marketplace source index | | `fixtures/marketplace/*.json` | Marketplace index, availability, source URL, and install review golden cases | @@ -192,15 +194,18 @@ contains a machine path, and the host decides where state lives. | --- | --- | | `${pluginDir}` | Root of the installed, read-only package. It must be used at most once per value and be followed by a `/`-rooted path inside the package; that path may vary only by `${arch}`. | | `${stateDir}` | Writable directory the host keeps for this plugin's server and removes when the plugin is uninstalled. Not available in `validationArguments`. | -| `${workspaceStateDir}` | Writable directory for the server's state of the current workspace. Not available in `validationArguments`. | +| `${workspaceStateDir}` | Writable directory for the server's state of the current workspace. Not available in `validationArguments`, nor in a [plugin backend](#plugin-backend), which belongs to no workspace. | | `${arch}` | Host CPU architecture: `arm64` or `x86_64`. | | `${setting:}` | Current value of a declared [plugin setting](#plugin-settings-api-2). Not available in `validationArguments`, and never inside a package path. | `plugins.declarative.languageServerLaunchPlan` expands a server's launch. The host selects the process with exactly one of `languageID` (a language server), -`buildSystemID` (a build provider), and `vcsProviderID` (a VCS provider), passes `{pluginDirectory, -stateDirectory, workspaceStateDirectory, architecture, settings?}`, and -receives: +`buildSystemID` (a build provider), `vcsProviderID` (a VCS provider), and +`backend: true` (the [plugin backend](#plugin-backend)), passes `{pluginDirectory, +stateDirectory, workspaceStateDirectory?, architecture, settings?}`, and +receives. `workspaceStateDirectory` may be omitted only when the selected +process does not use `${workspaceStateDir}`; otherwise the request is +`plugin.launch.invalidRequest`. | Field | Meaning | | --- | --- | @@ -214,10 +219,11 @@ receives: | `settings` | Effective value of every declared setting that produced this plan; absent when the plugin declares none. | | `buildProvider` | Present only for a `buildSystemID` selection: `{buildSystemID, executesProjectCode, mayUseNetwork, languageServer?}`. `languageServer` names the language whose server is the provider; the rest of the plan is then that server's plan, and the host reuses its running session instead of starting a second process. | | `vcsProvider` | Present only for a `vcsProviderID` selection: the [provider descriptor](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md#provider-descriptor) the host keeps for the session. | +| `backend` | Present only for a `backend` selection: `{hostServices}`, the sorted [host services](#host-services-protocol-version-2) the plugin's permissions grant. The host passes them to `lsp.startServer.hostServices` unchanged. | A language without a server, an undeclared build system, a build system -without a provider, and an undeclared VCS provider are -`plugin.launch.unavailable`. Core never creates or +without a provider, an undeclared VCS provider, and a plugin without a +backend are `plugin.launch.unavailable`. Core never creates or reads any of these paths. The command also serves API 1 manifests, whose arguments are returned unchanged. @@ -394,13 +400,15 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | --- | --- | --- | | `languages` | `languages[]` | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | -| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, and of VCS providers: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | +| `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | | `executionProviders` | `executionProviders[]` | `1` | | `settings` | `settings[]` and `settingActions[]` | `1`, `2` (adds [placement, scope, effect, page buttons](#settings-v2-placement-scope-effect-and-page-buttons)) | | `ui` | `ui` | `1`, `2` (adds [web content](#web-content-ui-2)) | | `buildSystems` | Declarative build detection, models, and command plans | `1` | | `buildProviders` | `buildSystems[].provider`, including own-process and shared language-server providers | `1` | | `vcsProviders` | `vcsProviders[]` | `1` | +| `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | +| `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process for its pages | `1` | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -422,7 +430,8 @@ moving a level 2 manifest to level 3 only adds `requires`. own process or shares a language server. A template-only build plugin must omit `buildProviders`. Launch support alone does not implement Provider negotiation, requests, and lifecycle. Every VCS provider starts its own - process, so `vcsProviders` always comes with `serverProcess`. + process, so `vcsProviders` always comes with `serverProcess`, and so does + `backend`. - The base fields (`schemaVersion`, `id`, `displayName`, `version`, `vendor`, `permissions`, `activationEvents`, and `requires` itself) change only with a new `pluginAPIVersion`. @@ -449,7 +458,8 @@ providers and does not declare `buildProviders`. Provider plugins are therefore filtered from the market and refused by declarative validation and install review; template-only build plugins remain available. It does not declare `vcsProviders` either, so VCS provider plugins are filtered the same way until -the macOS host starts providers. Core supports `buildProviders: 1` and +the macOS host starts providers. It declares `backend: [1]`: it starts +plugin backends and serves their credentials from the Keychain. Core supports `backend: 1`, `buildProviders: 1`, and `vcsProviders: 1` for validation and plans without a host table. Windows has no declarative plugin host and sends no `hostCapabilities`, so it offers no level 3 version. @@ -484,8 +494,11 @@ Rules for publishing across levels and capability versions: | `process.test` | any provider declares `test` | | `process.build` | `buildSystems` is not empty | | `process.vcs` | `vcsProviders` is not empty | +| `project.generate` | `projectGenerators` is not empty | | `ui.contribute` | `ui` is declared | | `ui.web` | a tool window shows [web content](#web-content-ui-2) | +| `backend.launch` | `backend` is declared | +| `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | | Activation event | Meaning | | --- | --- | @@ -634,6 +647,7 @@ the plugin, sorted by `kind`, language, and tool: | `runtime` | `tool`, `minimumVersion`? | Runtime the host discovers and passes to a language server, build provider, or VCS provider | | `run` | `languageID`, `executable`, `arguments` | Run command and its argument template | | `test` | `languageID`, `executable`, `workspaceArguments`, `fileArguments`, `testCaseArguments` | Test command and its argument templates | +| `projectGenerator` | `generatorID`, `command`, `arguments`, `environment`, `collect`, `mayUseNetwork`, `timeoutSeconds`, `when` | A `command` step of a [project generator](#project-generators); `files` and `archive` steps start no program | Each entry carries everything that decides what actually runs, so an update that keeps the same executable but changes its arguments, argument templates, @@ -993,20 +1007,190 @@ its VCS client (`vcsTool`) and `mayUseNetwork`. Requests go through `vcs.decodeReply`. The fields, operations, reply shapes, and error codes are defined in [the VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md). +## Plugin backend + +A plugin with its own pages may need code that a page cannot run: database +drivers, network clients, file formats. `backend` declares one long-lived +process that serves only the plugin itself, independent of any build system, +language, or workspace: + +```json +"pluginAPIVersion": 3, +"requires": { "backend": 1, "serverProcess": 1, "ui": 2 }, +"permissions": ["backend.launch", "credentials.store", "ui.contribute", "ui.web"], +"backend": { "launch": { "kind": "packaged", "path": "bin/backend" }, "arguments": ["--state", "${stateDir}"] } +``` + +- `backend` holds exactly the [`serverProcess`](#capability-versions-level-3) + launch fields, with the same templates and install review; the review lists + it as `packagedBackend`, `systemCommandBackend`, or `runtimeBackend`. It + needs `pluginAPIVersion` 3, `requires.backend: 1`, `requires.serverProcess: + 1`, and the `backend.launch` permission. `fixtures/backend.plugin.json` is + the example; the backend cases of `fixtures/invalid-manifests.json` are the + rejections. +- In `backend` 1 only the plugin's own [web pages](#web-content-ui-2) call it, + so a backend without a web tool window is `plugin.manifest.invalid`. +- It belongs to no workspace: `${workspaceStateDir}` is refused, and its + writable state is `${stateDir}`. The host starts it with `${stateDir}` as + root and working directory. +- The host keeps at most one backend process per plugin, starts it on the + first `functions.*` call from one of the plugin's pages, and ends it when + the plugin is disabled, updated, or uninstalled, and when the host quits. + Its consent is the install review: it needs no per-workspace prompt. +- The backend speaks the [plugin-function protocol](#plugin-functions) and, + from version 2, may ask the host for the + [host services](#host-services-protocol-version-2) its permissions grant. + It owns no domain contract: it never answers `lithe/build/*` or VCS + requests. +- A plugin publishes one archive per platform, so its packaged path does not + vary by `${arch}`; a macOS archive may carry a universal binary. + +## Project generators + +`projectGenerators` (requires `projectGenerators` 1 and the `project.generate` +permission) adds entries to the host's New Project window. The host owns the +window: the entry list and its search, the project name and location, the +"create Git repository" option, the empty project, and the Create button. A +generator declares only its own fields, the languages and build systems the +result needs, and the steps that produce it. No host code branches on a +generator ID. + +```json +"projectGenerators": [{ + "id": "spring-boot", + "displayName": "Spring Boot", + "section": "generator", + "icon": {"path": "icons/spring-boot.svg", "mode": "original"}, + "fields": [ + {"id": "groupId", "type": "text", "title": "Group", "default": "com.example", "pattern": "[a-z][a-z0-9.]*"}, + {"id": "packageName", "type": "text", "title": "Package name", + "default": "${field.groupId}.${project.name|package}", "pattern": "[a-z][a-z0-9.]*"}, + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"} + ], + "requirements": [{"languageID": "java"}, {"buildSystemID": "maven"}], + "steps": [{"kind": "archive", "url": "https://start.spring.io/starter.zip", + "query": [{"name": "packageName", "value": "${field.packageName}"}]}] +}] +``` + +| Field | Meaning | +| --- | --- | +| `id` | Lowercase letters, digits, and hyphens; unique in the plugin. | +| `section` | `project` lists the entry under "New Project" (a plain project of a language); `generator` lists it under "Generators" (a framework or scaffolding tool). | +| `order` | 0–1000, default 100. Entries are sorted by section, `order`, then name. | +| `fields` | The generator's inputs, drawn and evaluated in order. A field may read only fields declared before it. | +| `requirements` | `languageID` or `buildSystemID` (optionally `when`) the created project needs. The catalog lists the installed plugins that provide each, with their enabled state, so the host can offer to enable one or open the marketplace. A requirement never blocks creation. | +| `steps` | Run in order in a staging directory that becomes the project root. | +| `openFiles` | Project-relative files the host opens after creating the project, optionally with `when`. | + +### Fields + +| `type` | Value | Properties | +| --- | --- | --- | +| `text` | A string | `default` (a template over `${project.name}` and earlier fields, re-evaluated until the user edits the field), `pattern` (matched as a whole), `maxLength` (default 256, at most 1024), `placeholder`, `optional`, `maximumVersion` | +| `enum` | One option | `options` (1–16 `{value, title}`), `default` (the first option otherwise), `presentation` (`segmented` or `menu`), `maximumVersion` | +| `boolean` | `true` or `false` | `default` | +| `multiSelect` | A list of options, kept in declaration order | `options` (1–128, each with an optional `group` heading), `default` | +| `runtime` | A host-discovered installation | `runtime` (`java`), `minimumVersion`, `optional`. The host chooses the initial value. | + +`maximumVersion` is a template over earlier fields naming the highest version +the value may be, compared numerically by dotted components. A target Java +release that must not exceed the selected JDK declares +`"maximumVersion": "${field.jdk.major}"`; while the bound expands to an empty +string (no JDK chosen yet) nothing is checked. + +Every field takes `id` (lowerCamelCase), `title`, `description` (shown behind +an info tip), `advanced` (listed under the collapsed "Advanced Settings" +group), and `visibleWhen`. A hidden field is not validated, and templates and +conditions read it as absent. + +Text values never start with `-` and never contain control characters, so a +value cannot become an option or split a line. A project name is one +directory name: not empty, at most 128 characters, no `/`, `\`, `:`, or +control characters, not `.` or `..`, no leading `-`, and no surrounding +spaces. + +### Conditions + +`when` and `visibleWhen` take one condition or a list that must all hold: +`{"field": "", "equals" | "notEquals": }` or `{"field": "", +"oneOf": [, …]}`. Values are option values for `enum`, `true`/`false` +for `boolean`, and strings for `text` and `runtime` (`notEquals: ""` tests +that an optional field is filled). A condition on a hidden field is false. + +### Templates + +Strings in the manifest use `${…}`: `${project.name}`, `${field.}` (a +text or option value, `true`/`false`, a comma-separated list, or a runtime's +home directory), `${field..version}`, and `${field..major}` (`21` for +`21.0.4`, `8` for `1.8.0_412`). Modifiers follow `|` and apply in order: +`path` turns dots into `/`, `package` makes a valid package name +(`com.Example.my-app` → `com.example.myapp`), and `lower` lowercases. + +Template files use `{{ … }}` with the same variables, because POMs, Gradle +scripts, and properties files already contain `${…}` that must reach the +project unchanged. Only `{{` followed by `project.` or `field.` is a +variable; any other `{{` is copied as text. A file name may also contain +`${…}` variables, although a step's `target` is the portable place for them. +A path segment that starts with `dot-` becomes a dotfile (`dot-gitignore` → +`.gitignore`), because packages keep no hidden files. Hidden files in a +template directory are skipped; symbolic links are refused. + +### Steps + +| `kind` | Properties | The host… | +| --- | --- | --- | +| `files` | `source` (package-relative directory), `target` (project-relative directory template), `when` | writes the files of the plan, setting the executable bit where the template file has one. | +| `command` | `command` (a bare executable name), `arguments` (strings or `{value, when}`), `environment`, `collect`, `mayUseNetwork`, `timeoutSeconds` (default 600, at most 1800), `when` | resolves the executable like a build tool, runs it with the staging directory as the working directory, and, with `collect`, moves the contents of that subdirectory up into the project root. Arguments that expand to an empty string are left out. | +| `archive` | `url` (no query or fragment), `query` (`{name, value, when, omitEmpty}`), `when` | downloads the zip and calls `project.extractArchive` into the staging directory. | + +Archive addresses must expand to HTTPS URLs (HTTP only for `localhost`, +`127.0.0.1`, or `[::1]`) without credentials. The URL may come from a field, +such as a configurable Spring Initializr server; the host shows the server +before creating. Template directories are limited to 1024 files, 1 MiB per +file, and 8 MiB in total; archives to 20,000 entries and 512 MiB extracted. + +### Commands + +| Command | Payload | Result | +| --- | --- | --- | +| `project.generatorCatalog` | `{plugins: [{manifest, enabled}], hostCapabilities?}` | `catalog: {generators: [{pluginID, pluginName, pluginEnabled, generator, requirements: [{languageID or buildSystemID, providers: [{pluginID, pluginName, enabled}]}], usesNetwork}], issues: [{pluginID, error}]}` | +| `project.generatorForm` | `{manifest, generatorID, form}` | `form: {projectName: {problem}, fields: [{id, visible, value, problem?}], activeRequirements, valid}` | +| `project.generationPlan` | `{manifest, generatorID, packagePath, form}` | `plan: {projectName, steps, openFiles, runtimes: [{field, runtime, home, version}], usesNetwork}` | +| `project.checkName` | `{projectName}` | `name: {problem}` | +| `project.extractArchive` | `{archivePath, destination}` | `extracted: {files}` | + +`form` is `{projectName, values, edited, runtimes}`. `values` maps field IDs +to values; a text value counts only when its ID is in `edited`, so derived +defaults keep following the fields they read. `runtimes` maps runtime field +IDs to `{home, version}` of the user's selection. A field `problem` is one of +`required`, `tooLong`, `invalidFormat`, `unsupportedValue`, `runtimeTooOld`, +and `versionTooHigh`; hosts localize by code. Plan steps are `{kind: +"writeFiles", files: [{path, contents, executable}]}`, `{kind: "command", +command, arguments, environment, collect, mayUseNetwork, timeoutSeconds}`, and +`{kind: "archive", url, host, format: "zip"}`. + +After the steps, the host moves the staging directory to its final location +(refusing an existing one), may record a `java` runtime as the project's JDK, +creates the Git repository when asked, and opens the project. A failure or +cancellation removes the staging directory and leaves nothing behind +(`plugin.project.generationFailed`). + ## Plugin functions -A plugin backend (a language server or build provider process the plugin -starts) can offer **plugin functions**: plugin-private actions with +A plugin backend (a language server, build provider, or +[plugin backend](#plugin-backend) process the plugin starts) can offer +**plugin functions**: plugin-private actions with serializable params and results, which the host invokes on behalf of the same plugin, for example from that plugin's own window. Functions complement the -typed domain protocols; they never replace them. Version 1 of the protocol is +typed domain protocols; they never replace them. Versions 1 and 2 of the protocol are implemented in Rust Core (`rust/lithe-core/src/lsp/interface/plugin_function.rs`). macOS starts every build provider process as a session of the plugin that owns it and invokes its functions from that plugin's web pages (the `functions.*` bridge methods below). A provider served by a shared language server is started by the host for every plugin of its language, so it serves no plugin's functions. Windows has no plugin backend host. The -[official Node SDK](#official-backend-sdk) implements the backend side. +[official SDKs](#official-backend-sdk) implement the backend side. ### Capability ownership @@ -1030,6 +1214,7 @@ Two rules keep these directions from forming a loop: domain registry. Each is added to a protocol version together with its params, permission, and the operation it must belong to. Version 1 offers none: every `lithe/host/*` request is refused with `plugin.host.unavailable`. + Version 2 adds the [credentials](#host-services-protocol-version-2) service. ### Declarative or backend implementation @@ -1051,7 +1236,7 @@ Two rules keep these directions from forming a loop: ### Negotiation For a session owned by a plugin (`lsp.startServer.pluginId`), the host offers -`capabilities.experimental.litheFunctions: { "versions": [1] }` in +`capabilities.experimental.litheFunctions: { "versions": [1, 2] }` in `initialize`, next to and independent of `litheBuild`. Host-owned sessions do not offer or accept this protocol, retain no plugin-function registry, and reject register/unregister with `-32601`. A plugin backend that offers @@ -1152,6 +1337,42 @@ A backend reports progress of a running call with the notification normalized report. A malformed report, or one for an operation that is not running on that session, is dropped. +### Host services (protocol version 2) + +A backend that negotiated version 2 may send these requests to the host. +Each service is granted by one manifest permission; Core receives the +granted services as `lsp.startServer.hostServices` (from the launch plan's +`backend.hostServices`) and checks every request before the host sees it. + +| Request | Params | Result | Permission | +| --- | --- | --- | --- | +| `lithe/host/credentials/get` | `{ key }` | `{ secret }`, `null` when nothing is stored | `credentials.store` | +| `lithe/host/credentials/set` | `{ key, secret }` | `null` | `credentials.store` | +| `lithe/host/credentials/delete` | `{ key }` | `null`, also when nothing was stored | `credentials.store` | + +- A `key` is 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`; a `secret` + is a string of at most 16 KiB. Unknown params are refused so a later + version can add them. +- Credentials are private to the plugin ID: the host stores them in the + platform's secret store (the Keychain on macOS) in a namespace of that ID, + never in `${stateDir}` or the package. They survive disabling and updating + the plugin and are deleted when it is uninstalled. +- Core refuses, without involving the host, in this order: a session that + negotiated version 1 or a method version 2 does not define (`-32601`, + `plugin.host.unavailable`), a service the plugin is not granted (`-32601`, + `plugin.host.permissionDenied`), and malformed params (`-32602`, + `plugin.host.invalidParams`). The code is in `error.data.code`. +- An accepted request becomes the runtime event `hostRequest` with `method` + and `result: { hostRequestID, service, params }`. The host answers it with + `lsp.hostResponse { sessionId, hostRequestID, result? | error: { message } }`; + Core sends `result` (or `null`) to the backend, or `-32603` with + `plugin.host.failed` and the host's message cut to 1024 bytes. An unknown + `hostRequestID`, including one already answered, is `invalid_request`. +- A session holds at most 16 unanswered host requests; a further one is + refused with `plugin.host.failed`. Unanswered requests end with the session. +- `hostServices` needs `pluginId` and names only services this Core defines; + otherwise `lsp.startServer` is `invalid_request`. + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. @@ -1160,8 +1381,10 @@ running on that session, is dropped. function calls, and its function requests are answered with `-32601`. - A new host capability or wire field the other side must not ignore needs a new protocol version, offered next to the old one. -- `fixtures/function-protocol.json` holds the golden cases. Rust Core tests run - them, and a scripted backend covers routing, result, failure, cancellation, +- A backend that answers `version: 1` keeps working unchanged: version 2 + only adds host services. +- `fixtures/function-protocol.json` holds the golden cases, including the + host requests (`hostRequests`). Rust Core tests run them, and a scripted backend covers routing, result, failure, cancellation, deadline, late reply, revocation, and compatibility with a build-only backend. @@ -1187,17 +1410,29 @@ to shade into their backend. It enforces the same rules with the same error mapping, with one difference: on Java 8 it stops only a cancelled tool's direct child, and the host's process scope ends the rest. -Each SDK has a sample plugin (`sdk/*/examples/sample-build`), and both samples -answer one end-to-end scenario driven through a real Core session. That -repository runs it against the Lithe commit its contract copy pins, and weekly -against the latest `preview`, the branch Lithe changes merge into. - -Neither SDK adds a wire field: a backend written without one is equally -valid. +[`sdk/rust`](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/rust/README.md) is the SDK for +[plugin backends](#plugin-backend), which ship as packaged executables. It +implements plugin-function protocol versions 1 and 2, including the +`credentials` host service, but neither build nor VCS providers. It needs +Rust 1.87 or newer, depends only on `serde_json`, and is released +(`sdk-rust-v`) as a `.crate` archive with its SHA-256, or used as a +Git dependency pinned by the tag. It enforces the same rules: on Unix it +points stdout at stderr so only protocol frames reach the host, and a panic +ends only its call, reported like any unexpected failure. + +The Node.js and Java SDKs each have a sample build provider +(`sdk/*/examples/sample-build`) that answers one end-to-end scenario driven +through a real Core session; the Rust SDK's sample backend +(`sdk/rust/examples/sample-backend`) answers a plugin backend scenario in which +the test plays the host and serves the credential requests. That repository +runs them against the Lithe commit its contract copy pins, and weekly against +the latest `preview`, the branch Lithe changes merge into. + +No SDK adds a wire field: a backend written without one is equally valid. ## Outcomes and error codes -`plugins.declarative.*`, `plugins.marketplace.*`, `build.*`, and `vcs.*` commands return `{"status": "ok", …}` or +`plugins.declarative.*`, `plugins.marketplace.*`, `build.*`, `vcs.*`, and `project.*` commands return `{"status": "ok", …}` or `{"status": "rejected", "error": {"code", "message", "field"?}}`. A malformed command payload is a Core `invalid_request` failure instead. @@ -1243,8 +1478,17 @@ command payload is a Core `invalid_request` failure instead. | `plugin.function.invalidReference`, `plugin.function.invalidParams`, `plugin.function.unavailable`, `plugin.function.wrongPlugin`, `plugin.function.unknown` | Core | A [plugin-function](#plugin-functions) call is refused before it reaches the backend | | `plugin.function.failed`, `plugin.function.invalidResult`, `plugin.function.revoked` | Core | A plugin-function call ended without a usable result | | `plugin.function.invalidRegistration` | Core | A backend's register or unregister request was refused as a whole | +| `plugin.project.unavailable` | Core | The plugin declares no such project generator | +| `plugin.project.invalidInput` | Core | The project name or a field value is not accepted; `field` is `projectName` or `values.`, or a step whose expanded path leaves the project | +| `plugin.project.templateInvalid` | Core | A template directory is missing, too large, contains a symbolic link or non-UTF-8 file, or uses an unknown variable | +| `plugin.project.invalidURL` | Core | An archive address does not expand to an HTTPS URL (HTTP only for loopback hosts) without credentials, query, or fragment | +| `plugin.project.archiveInvalid` | Core | A downloaded archive is not a zip, has an entry outside the destination, a symbolic link, a file that already exists, or expands beyond the size limit | +| `plugin.project.generationFailed` | Host | A command step failed or timed out, a download failed, or the project could not be moved into place | | `plugin.function.recursiveCall` | Core | A backend asked the host to dispatch a domain operation or function | | `plugin.host.unavailable` | Core | A backend asked for a host capability the negotiated protocol does not offer | +| `plugin.host.permissionDenied` | Core | A backend asked for a [host service](#host-services-protocol-version-2) its plugin's permissions do not grant | +| `plugin.host.invalidParams` | Core | A host-service request has malformed or unknown params | +| `plugin.host.failed` | Core | The host could not complete a host-service request, or too many were unanswered | Host-facing plugin states are `notInstalled`, `installed` (enabled or disabled), `running`, `restartRequired`, `updateRequired`, and `failed`. @@ -1358,15 +1602,15 @@ their output appears in the host's Build output window. | `build.setOptions` | `{ instance, values }` | `{ options }`, checked by `build.resolveOptions` and stored like values set in the host's option list | | `workspace.openFile` | `{ path, line?, column? }`, a path inside the workspace | `{}` | | `ui.getState`, `ui.setState` | none, `{ state }` | `{ state }`, `{}`; JSON of at most 64 KiB per window and workspace | -| `functions.list` | `{ instance }` | `{ functions }`: sorted IDs of the functions the instance's provider currently offers; starts the provider | -| `functions.invoke` | `{ instance, function, params?, call? }`; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | +| `functions.list` | `{ instance? }` | `{ functions }`: sorted IDs of the functions the instance's provider, or without `instance` the plugin's [backend](#plugin-backend), currently offers; starts that process | +| `functions.invoke` | `{ instance?, function, params?, call? }`, without `instance` a call to the plugin's backend; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | | `functions.cancel` | `{ call }` | `{}`; a call still awaiting consent never starts, a call waiting for its provider to start ends at once while the start goes on, and a call that already ended is ignored | | Event | Payload | | --- | --- | | `build.instancesChanged` | `{ instances }`, as `build.instances` returns them | | `build.runChanged` | `{ run, instance, state, exitCode? }`, `state` one of `running`, `succeeded`, `failed`, `cancelled` | -| `functions.progress` | `{ call, instance, function, message?, percentage? }`, only for a call made with `call` | +| `functions.progress` | `{ call, instance?, function, message?, percentage? }`, only for a call made with `call`; `instance` absent for a backend call | | `ui.themeChanged` | `{ theme }` | Bridge errors are `plugin.web.unknownMethod`, `plugin.web.invalidRequest`, @@ -1382,20 +1626,21 @@ cancelled or the provider stopped. A page has at most 16 function calls accepted at once, counting calls still awaiting consent, and closing it cancels them. Reusing a `call` ID is `plugin.web.invalidRequest`, also after that call ended. -`functions.list` and `functions.invoke` ask for the same consent as the -host's provider requests: once given for a plugin and workspace, it holds -until the project is closed. -macOS implements web content with WKWebView and advertises `ui: [1, 2]`. +`functions.list` and `functions.invoke` with an `instance` ask for the same +consent as the host's provider requests: once given for a plugin and +workspace, it holds until the project is closed. Calls to the plugin's +backend ask for none; the install review covered it. +macOS implements web content with WKWebView and advertises `ui: [1, 2, 3]` +(including the native controls described below). Windows has no WebView2 host yet and still advertises `ui: [1]`, so Core refuses `ui` 2 plugins there with `plugin.api.unsupported`. ### Future public extension direction (not implemented in API level 2) - Plugins may bring their own UI as [web content](#web-content-ui-2) inside - host-owned windows. A separately versioned JSON component tree - (Tree/List/Form/Input/Button, data sources and events) rendered by native - platform adapters remains the direction for plugins that want native - controls without writing a page. Built-in or trusted native modules may + host-owned windows. Native trees/lists and panel actions now use the separately versioned + [native schema](#host-rendered-native-content-ui-3-native-schema-1). + Forms and broader component composition remain follow-up work. Built-in or trusted native modules may register Native View/Renderer Factories under the same host window management. This does not restore arbitrary dynamic third-party Swift Bundle loading; ABI, signing and isolation need separate design. @@ -1485,8 +1730,10 @@ optional; without it the same resource is used in both themes. Brand authors must make that resource legible in both themes or provide a dark variant. `host` cannot be combined with resource paths or original-color mode. -The host owns icon size: 20pt tool-window rail, 16pt actions/menus/palette, 13pt -window header. Aspect ratio is preserved inside that slot. Plugin-specified +The host owns icon size: 20pt tool-window rail and 16pt actions/menus/palette. +Panel titles are text-only: no title icon, outline or bottom divider. A declared +window icon remains available in the activity rail and management menus. +Aspect ratio is preserved inside each icon slot. Plugin-specified size, color, stroke, arbitrary style, remote URLs and SF Symbol names are rejected. Stroke geometry belongs to the asset; authors should follow the host icon weight, rather than expecting the host to rewrite paths or PNG pixels. @@ -1601,3 +1848,112 @@ without it reject new-style plugins before decoding unknown fields. Published 0.1.0/0.1.1 archives remain immutable; migration is a new plugin version. See [`build-tool-path.plugin.json`](fixtures/build-tool-path.plugin.json). + +### Host-rendered native content (`ui` 3, native schema 1) + +macOS supports `requires.ui: 3` for a tool window whose sole body is `native`. +Text and Web windows retain their existing behavior. Windows does not advertise +ui 3 and rejects these packages before installing them. The independently +versioned [native schema](native-ui.schema.json) has no style, script, HTML or +host-action fields. The host renders every control with its shared theme. + +See [native-panel.plugin.json](fixtures/native-panel.plugin.json) and the runnable +[Node SDK Sample Explorer](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/sdk/node/examples/sample-build/plugin.json). +`native.kind` is `tree`, `list` or `form`; `nodes` contains literal `id`, `label` and +optional `children`. IDs are stable, case-sensitive ASCII letters/digits plus +`._-` (1–128 bytes). The component, initial nodes and actions have distinct IDs. +The entire node set is limited to 2000 nodes and depth 16 (root depth 0); labels +are nonblank and at most 1024 UTF-8 bytes. Lists reject nested children. + +A `form` declares `nodes: []`, an empty `contextMenu`, at most 32 `controls` +and 16 `buttons`; at least one control or button is required. +Tree/list panels cannot contain controls. Each control has a unique `id`, literal +`label` (1–256 UTF-8 bytes), `kind`, initial `value`, optional `enabled` (default +true), and optional `onCommit` referencing a declared `invoke` action that does +not require a selection. An optional literal `info` (1–1024 UTF-8 bytes) renders the shared information +icon beside the label; tooltip timing, pinning, accessibility and styling are host-owned. + +- `input` / `search`: string `value` (at most 1024 UTF-8 bytes), optional `placeholder` + (at most 256 UTF-8 bytes), no options. Uses the IDE settings text-field owner. +- `select`: string `value` equal to one of 1–128 unique `options: [{id,label}]`; + option IDs use the standard identifier rule, labels are 1–256 UTF-8 bytes. + No placeholder. Uses `LitheSettingsSelect`, including its popup and keyboard behavior. +- `segmented`: string value and 1–8 options, using the same option contract and + the shared segmented selector; no placeholder. +- `editor`: string value up to 8192 UTF-8 bytes, optional `rows` (2–12, default 4), + no placeholder/options/onCommit. Reuses the shared multiline editor; Enter inserts + a newline and form buttons submit its current value. +- `stepper`: finite numeric value, required `minimum`, `maximum` and positive + `step`; bounds and step are limited to ±1e9/1e9, and value must fit the range. + No placeholder/options. Uses the shared settings stepper and bound behavior. +- `checkbox`: boolean `value`, no options or placeholder. Uses `LitheSettingsCheckbox`. + +Forms may declare `layout: {"columns":12}` (1–12 columns). Each control may +provide `span` (1–columns); omitted span fills a row. Controls pack in declaration +order, wrapping before a span would exceed the row. Without layout, the form +retains its original single-column presentation and must omit span. Layout is +form-only. Gaps, alignment and breakpoints remain host-owned; plugins cannot +supply pixels, CSS or gap values. macOS uses a native `Layout`, 12pt gaps, +top-aligned variable-height rows, and switches to one column below 640pt of +content width. Reflow preserves the same controls, local values and focus. + +Buttons reference an existing non-selection action through `action`, have stable +`id`, literal `label`, optional `primary`, `enabled` and `info`. +Text actions reuse the host's shared natural-width button style and 6pt action +spacing; a narrow action group falls back to vertical stacking. Deprecated +`buttons[].width` (`content`/`fill`) and `buttons[].span` are accepted for older +experimental packages, but do not change button width or action-group layout. +Grid spans continue to apply to value controls. The declaration cannot override +button colors, fonts, chrome, padding or fixed/max widths. + +Input/search editing saves local values without executing plugin code; Return commits. +Selectors, segmented selectors, steppers and checkboxes commit after a changed value. Omit `onCommit` to keep +a field local. Commits pass **all** current fields as `params.values` (strings, +booleans and numbers keyed by stable control ID), together with `selectedID: null`. +The subsequent form source call also receives `{values}` and returns `{nodes: []}`. +Disabled fields still contribute their values; they cannot edit or commit. +A pending call disables all fields. Values use the existing 64 KiB per-plugin, +per-window, per-workspace state store; removed fields, invalid types and removed +options are discarded when restoring. Provider replies never overwrite local +fields. Form declarations and choices are static in this version. +See [native-form.plugin.json](fixtures/native-form.plugin.json) and +[host UI acceptance fixture](https://github.com/1lck/Lithe-IDEA/blob/preview/macos/Tests/Fixtures/native-ui-lab/README.md) for acceptance. + +An optional `source` names this plugin's `buildSystemID` and a function returning +`{ nodes: [...] }`. This phase uses the existing provider transport; it adds no +new backend, provider registry, or process protocol. There must be exactly one +detected instance of that build system in the workspace; zero or multiple +matches produce an error instead of silently targeting a different root. +Static panels omit `source` and declare empty actions and placements. + +`actions` declares at most 64 independent handlers with `id`, literal `title`, +`kind` (`refresh` or `invoke`) and `requiresSelection`. A refresh reloads the +source and cannot require selection or name a function. An invoke names a +`function` and receives `{ selectedID: string | null }` (forms also include `values`). Invocations return an +optional `{ message }`, then the host reloads the source. `toolbar` and +`contextMenu` contain distinct references to declared action IDs in display +order. Toolbar actions are icon-only and appear when the pointer enters the whole +panel, like the host hide control; keyboard and accessibility focus also reveal them. +An optional `icon: {"host":"search"}` reuses the host icon vocabulary (including +`reset`); native actions do not load custom package icons. Without an icon, refresh +uses the refresh icon and invoke uses the generic run icon. The literal title is +the tooltip and accessibility name. A context-menu action selects its clicked row before running; selecting +a row alone never starts a provider function. These are panel-local actions; +this phase does not change API-v2 commands into arbitrary function handlers. + +The host allows one operation per panel, disables actions while busy, and +shows loading, empty, failure and success feedback. A source reply is at most +1 MiB and follows the same node limits; invalid data preserves the last valid +rows. Selection and expansion use stable IDs and the existing per-plugin, +window and workspace state store (at most 64 KiB). Arrow keys move selection, +expand/collapse nodes and select a parent; hidden/reopened panels restore state. +Provider calls retain existing consent, scope checks, timeout and cancellation. +Hiding a native panel cancels its pending operation. Disable, package replacement +and workspace close synchronously invalidate it through the existing shared +plugin-page lifecycle; late replies cannot publish. All state is outside the +installed package and application bundle. + +Schema 1 loads the complete node set on refresh. Forms, lazy child data sources, +multi-instance selectors, global function commands, native renderer factories +and Windows rendering remain follow-up work under #1209; this subset does not +claim completion of that whole issue. diff --git a/contract/v1/fixtures/backend.plugin.json b/contract/v1/fixtures/backend.plugin.json new file mode 100644 index 0000000..b5bd06b --- /dev/null +++ b/contract/v1/fixtures/backend.plugin.json @@ -0,0 +1,49 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.backend", + "displayName": "Backend", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 1, + "serverProcess": 1, + "ui": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "credentials.store", + "ui.contribute", + "ui.web" + ], + "activationEvents": [ + "onView:dev.lithe.fixture.backend.window" + ], + "languages": [], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.backend.window", + "title": "Backend", + "placement": "bottom", + "web": { + "entry": "ui/index.html" + } + } + ] + }, + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/contract/v1/fixtures/function-protocol.json b/contract/v1/fixtures/function-protocol.json index 9cbf35e..98d1326 100644 --- a/contract/v1/fixtures/function-protocol.json +++ b/contract/v1/fixtures/function-protocol.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "protocol": "litheFunctions", - "offer": { "versions": [1] }, + "offer": { "versions": [1, 2] }, "negotiation": [ { "name": "no answer leaves the protocol off", @@ -36,9 +36,14 @@ }, "expected": { "functions": ["install.finish", "install.prepare"] } }, + { + "name": "version 2 negotiates the same registry and adds host services", + "capabilities": { "experimental": { "litheFunctions": { "version": 2, "functions": [{ "id": "query.run" }] } } }, + "expected": { "functions": ["query.run"], "version": 2 } + }, { "name": "a version the host never offered is not negotiated", - "capabilities": { "experimental": { "litheFunctions": { "version": 2 } } }, + "capabilities": { "experimental": { "litheFunctions": { "version": 3 } } }, "expected": { "functions": null } }, { @@ -266,5 +271,87 @@ "method": "workspace/applyEdit", "expected": { "code": null } } + ], + "hostRequests": [ + { + "name": "version 1 sessions get no host service even when granted", + "version": 1, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "a granted credential read is forwarded to the host", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a credential write carries the secret", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod:password", "secret": "s3cret" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a credential delete names only the key", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/delete", + "params": { "key": "connection.prod" }, + "expected": { "forward": "credentials" } + }, + { + "name": "a service the plugin's permissions do not grant is refused", + "version": 2, + "hostServices": [], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod" }, + "expected": { "rpcCode": -32601, "code": "plugin.host.permissionDenied" } + }, + { + "name": "a host method version 2 does not define is unavailable", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/process.run", + "params": {}, + "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } + }, + { + "name": "a key outside the alphabet is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "../other-plugin" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "an empty key is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/delete", + "params": { "key": "" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "a write without a string secret is invalid", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/set", + "params": { "key": "connection.prod", "secret": 42 }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + }, + { + "name": "unknown params are invalid so a later version can add them", + "version": 2, + "hostServices": ["credentials"], + "method": "lithe/host/credentials/get", + "params": { "key": "connection.prod", "scope": "global" }, + "expected": { "rpcCode": -32602, "code": "plugin.host.invalidParams" } + } ] } diff --git a/contract/v1/fixtures/invalid-manifests.json b/contract/v1/fixtures/invalid-manifests.json index 16d371a..79b27a9 100644 --- a/contract/v1/fixtures/invalid-manifests.json +++ b/contract/v1/fixtures/invalid-manifests.json @@ -959,6 +959,60 @@ "base": "build-command-output-file.plugin.json", "edits": [{ "op": "set", "path": "/buildSystems/0/tasks/0/category", "value": ["Tasks", " "] }], "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend needs its capability in requires", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/requires/backend" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend capability version this Core does not know", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/requires/backend", "value": 2 }], + "expectedCode": "plugin.api.unsupported" + }, + { + "name": "backend needs serverProcess", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/requires/serverProcess" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend needs the backend.launch permission", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], + "expectedCode": "plugin.permission.undeclared" + }, + { + "name": "backend serves only web pages", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/ui/toolWindows/0/web" }, { "op": "set", "path": "/ui/toolWindows/0/text", "value": "No page" }, { "op": "set", "path": "/requires/ui", "value": 1 }, { "op": "set", "path": "/permissions", "value": ["backend.launch", "credentials.store", "ui.contribute"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend is not bound to a workspace", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/arguments", "value": ["--cache", "${workspaceStateDir}"] }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend launch stays inside the package", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/launch/path", "value": "../backend" }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "backend rejects unknown fields", + "base": "backend.plugin.json", + "edits": [{ "op": "set", "path": "/backend/workspace", "value": true }], + "expectedCode": "plugin.manifest.invalid" + }, + { + "name": "credentials.store without a backend", + "base": "backend.plugin.json", + "edits": [{ "op": "remove", "path": "/backend" }, { "op": "set", "path": "/requires", "value": {"ui": 2} }, { "op": "set", "path": "/permissions", "value": ["credentials.store", "ui.contribute", "ui.web"] }], + "expectedCode": "plugin.manifest.invalid" } ] } diff --git a/contract/v1/fixtures/native-controls.plugin.json b/contract/v1/fixtures/native-controls.plugin.json new file mode 100644 index 0000000..49b82e2 --- /dev/null +++ b/contract/v1/fixtures/native-controls.plugin.json @@ -0,0 +1,407 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.native-controls", + "displayName": "Native public controls fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "ui": 3 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "ui.contribute" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onView:dev.lithe.fixture.native-controls.form" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "node" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": [ + "--state", + "${workspaceStateDir}" + ], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "--version" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + } + } + ], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.native-controls.form", + "title": "Public controls", + "placement": "bottom", + "native": { + "schemaVersion": 1, + "id": "form", + "kind": "form", + "nodes": [], + "controls": [ + { + "id": "name", + "kind": "input", + "label": "名称(输入后按回车)", + "value": "UI Lab", + "placeholder": "输入中文、English 或 😀🚀", + "onCommit": "changed", + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "query", + "kind": "input", + "label": "空输入框与占位提示", + "value": "", + "placeholder": "试试中文输入法、粘贴、撤销", + "onCommit": "changed", + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "environment", + "kind": "select", + "label": "环境(切换后自动提交)", + "value": "dev", + "options": [ + { + "id": "dev", + "label": "开发环境" + }, + { + "id": "test", + "label": "测试环境 / English" + }, + { + "id": "prod", + "label": "生产环境 😀" + } + ], + "onCommit": "changed", + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "notify", + "kind": "checkbox", + "label": "启用通知(切换后自动提交)", + "value": true, + "onCommit": "changed", + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "offline", + "kind": "checkbox", + "label": "离线模式(仅本地保存)", + "value": false, + "span": 6, + "info": "使用 IDE 公共控件。当前值会在提交时传给示例插件,界面样式由宿主管理。" + }, + { + "id": "locked-input", + "kind": "input", + "label": "禁用输入框", + "value": "不可编辑", + "enabled": false, + "span": 6, + "info": "使用 IDE 公共控件。此控件已禁用;悬停可查看说明。" + }, + { + "id": "locked-select", + "kind": "select", + "label": "禁用下拉选择", + "value": "locked", + "options": [ + { + "id": "locked", + "label": "固定选项" + } + ], + "enabled": false, + "span": 6, + "info": "使用 IDE 公共控件。此控件已禁用;悬停可查看说明。" + }, + { + "id": "locked-check", + "kind": "checkbox", + "label": "禁用复选框", + "value": true, + "enabled": false, + "span": 6, + "info": "使用 IDE 公共控件。此控件已禁用;悬停可查看说明。" + }, + { + "id": "search", + "kind": "search", + "label": "搜索框(回车提交)", + "value": "", + "placeholder": "搜索中文 / English / 😀", + "onCommit": "changed", + "info": "复用共享搜索输入、焦点样式、I形光标和清空按钮。清空仅修改本地值。", + "span": 6 + }, + { + "id": "notes", + "kind": "editor", + "label": "多行输入(使用下方按钮提交)", + "value": "第一行:中文\nSecond line 😀", + "rows": 4, + "info": "Enter 换行,支持组合输入、选择和撤销;最多8192 UTF-8字节。", + "span": 6 + }, + { + "id": "mode", + "kind": "segmented", + "label": "分段选择", + "value": "safe", + "options": [ + { + "id": "fast", + "label": "快速" + }, + { + "id": "safe", + "label": "安全" + }, + { + "id": "debug", + "label": "调试 😀" + } + ], + "onCommit": "changed", + "info": "复用设置页分段选择,传递稳定选项ID。", + "span": 6 + }, + { + "id": "concurrency", + "kind": "stepper", + "label": "数字步进器", + "value": 2, + "minimum": 1, + "maximum": 8, + "step": 1, + "onCommit": "changed", + "info": "最小1、最大8、步长1;到边界后对应按钮禁用。", + "span": 6 + }, + { + "id": "disabled-search", + "kind": "search", + "label": "禁用 · 搜索框(回车提交)", + "value": "", + "placeholder": "搜索中文 / English / 😀", + "onCommit": "changed", + "info": "复用共享搜索输入、焦点样式、I形光标和清空按钮。清空仅修改本地值。", + "enabled": false, + "span": 6 + }, + { + "id": "disabled-notes", + "kind": "editor", + "label": "禁用 · 多行输入(使用下方按钮提交)", + "value": "第一行:中文\nSecond line 😀", + "rows": 4, + "info": "Enter 换行,支持组合输入、选择和撤销;最多8192 UTF-8字节。", + "enabled": false, + "span": 6 + }, + { + "id": "disabled-mode", + "kind": "segmented", + "label": "禁用 · 分段选择", + "value": "safe", + "options": [ + { + "id": "fast", + "label": "快速" + }, + { + "id": "safe", + "label": "安全" + }, + { + "id": "debug", + "label": "调试 😀" + } + ], + "onCommit": "changed", + "info": "复用设置页分段选择,传递稳定选项ID。", + "enabled": false, + "span": 6 + }, + { + "id": "disabled-concurrency", + "kind": "stepper", + "label": "禁用 · 数字步进器", + "value": 2, + "minimum": 1, + "maximum": 8, + "step": 1, + "onCommit": "changed", + "info": "最小1、最大8、步长1;到边界后对应按钮禁用。", + "enabled": false, + "span": 6 + } + ], + "source": { + "buildSystemID": "sample", + "function": "form.nodes" + }, + "actions": [ + { + "id": "changed", + "title": "提交控件值", + "kind": "invoke", + "function": "form.changed", + "requiresSelection": false + }, + { + "id": "form-slow", + "title": "慢提交", + "kind": "invoke", + "function": "form.slow", + "requiresSelection": false + }, + { + "id": "form-fail", + "title": "模拟提交失败", + "kind": "invoke", + "function": "form.fail", + "requiresSelection": false + } + ], + "toolbar": [], + "contextMenu": [], + "layout": { + "columns": 12 + }, + "buttons": [ + { + "id": "secondary-content", + "label": "普通按钮(内容宽度)", + "action": "changed", + "width": "content", + "span": 6, + "info": "随文字宽度伸缩,保留公共最小宽度、外框和内边距。" + }, + { + "id": "secondary-fill", + "label": "普通按钮(填满栅格)", + "action": "changed", + "width": "fill", + "span": 6, + "info": "填满剩余空间,样式和高度仍由公共按钮控制。" + }, + { + "id": "primary-content", + "label": "主要按钮(内容宽度)", + "action": "changed", + "primary": true, + "width": "content", + "span": 6 + }, + { + "id": "primary-fill", + "label": "主要按钮(填满栅格)", + "action": "changed", + "primary": true, + "width": "fill", + "span": 6 + }, + { + "id": "disabled-button", + "label": "禁用普通按钮", + "action": "changed", + "enabled": false, + "span": 6 + }, + { + "id": "disabled-primary", + "label": "禁用主要按钮", + "action": "changed", + "primary": true, + "enabled": false, + "width": "fill", + "span": 6 + }, + { + "id": "slow-button", + "label": "慢提交(3秒,检查禁用/取消)", + "action": "form-slow", + "span": 6 + }, + { + "id": "failure-button", + "label": "模拟提交失败", + "action": "form-fail", + "width": "fill", + "span": 6 + } + ] + } + } + ] + } +} diff --git a/contract/v1/fixtures/native-form.plugin.json b/contract/v1/fixtures/native-form.plugin.json new file mode 100644 index 0000000..71dd640 --- /dev/null +++ b/contract/v1/fixtures/native-form.plugin.json @@ -0,0 +1,157 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.native-form", + "displayName": "Native form fixture", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "ui": 3 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "ui.contribute" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onView:dev.lithe.fixture.native-form.form" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "node" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": [ + "--state", + "${workspaceStateDir}" + ], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "--version" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + } + } + ], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.native-form.form", + "title": "Native form", + "placement": "bottom", + "native": { + "schemaVersion": 1, + "id": "form", + "kind": "form", + "nodes": [], + "controls": [ + { + "id": "name", + "kind": "input", + "label": "名称(输入后按回车)", + "value": "UI Lab", + "placeholder": "输入中文、English 或 😀🚀", + "span": 6 + }, + { + "id": "query", + "kind": "input", + "label": "空输入框与占位提示", + "value": "", + "placeholder": "试试中文输入法、粘贴、撤销", + "span": 6 + }, + { + "id": "environment", + "kind": "select", + "label": "环境(切换后自动提交)", + "value": "dev", + "options": [ + { + "id": "dev", + "label": "开发环境" + }, + { + "id": "test", + "label": "测试环境 / English" + }, + { + "id": "prod", + "label": "生产环境 😀" + } + ], + "span": 6 + }, + { + "id": "notify", + "kind": "checkbox", + "label": "启用通知(切换后自动提交)", + "value": true, + "span": 6 + } + ], + "actions": [], + "toolbar": [], + "contextMenu": [], + "layout": { + "columns": 12 + } + } + } + ] + } +} diff --git a/contract/v1/fixtures/native-panel.plugin.json b/contract/v1/fixtures/native-panel.plugin.json new file mode 100644 index 0000000..4103010 --- /dev/null +++ b/contract/v1/fixtures/native-panel.plugin.json @@ -0,0 +1,137 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.native-panel", + "displayName": "Sample Build", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { + "buildProviders": 1, + "buildSystems": 1, + "serverProcess": 1, + "ui": 3 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "languageServer.launch", + "process.build", + "ui.contribute" + ], + "activationEvents": [ + "onBuildSystem:sample", + "onView:dev.lithe.fixture.native-panel.explorer" + ], + "languages": [], + "buildSystems": [ + { + "id": "sample", + "displayName": "Sample", + "detection": { + "markers": [ + "lithe-sample.json" + ], + "nesting": "outermost" + }, + "invocation": { + "executables": [ + { + "kind": "command", + "command": "node" + } + ] + }, + "provider": { + "launch": { + "kind": "runtime", + "tool": "node", + "minimumVersion": "20.3", + "entry": "backend/main.mjs" + }, + "arguments": [ + "--state", + "${workspaceStateDir}" + ], + "executesProjectCode": true, + "mayUseNetwork": false + }, + "model": { + "source": "provider" + }, + "tasks": [ + { + "id": "build", + "displayName": "Build", + "group": "build", + "scopes": [ + "workspace" + ], + "arguments": [ + "--version" + ] + } + ], + "dependencies": { + "source": "provider", + "scopes": [ + "workspace", + "project" + ] + }, + "sync": { + "watch": [ + "lithe-sample.json" + ] + } + } + ], + "ui": { + "toolWindows": [ + { + "id": "dev.lithe.fixture.native-panel.explorer", + "title": "Sample Explorer", + "placement": "right", + "native": { + "schemaVersion": 1, + "id": "explorer", + "kind": "tree", + "nodes": [], + "source": { + "buildSystemID": "sample", + "function": "ui.nodes" + }, + "actions": [ + { + "id": "refresh", + "title": "Refresh", + "kind": "refresh", + "requiresSelection": false, + "icon": { + "host": "refresh" + } + }, + { + "id": "inspect", + "title": "Inspect", + "kind": "invoke", + "function": "ui.inspect", + "requiresSelection": true, + "icon": { + "host": "search" + } + } + ], + "toolbar": [ + "refresh", + "inspect" + ], + "contextMenu": [ + "inspect" + ] + } + } + ] + } +} diff --git a/contract/v1/fixtures/project-generator-templates/.gitattributes b/contract/v1/fixtures/project-generator-templates/.gitattributes new file mode 100644 index 0000000..59f82ba --- /dev/null +++ b/contract/v1/fixtures/project-generator-templates/.gitattributes @@ -0,0 +1,2 @@ +# Templates are emitted byte-for-byte, so checkouts must not convert line endings. +* -text diff --git a/contract/v1/fixtures/project-generator-templates/app/README.md b/contract/v1/fixtures/project-generator-templates/app/README.md new file mode 100644 index 0000000..af59f58 --- /dev/null +++ b/contract/v1/fixtures/project-generator-templates/app/README.md @@ -0,0 +1,3 @@ +# {{ project.name }} + +Build files keep ${placeholders} and {{other}} text unchanged. diff --git a/contract/v1/fixtures/project-generator-templates/app/dot-gitignore b/contract/v1/fixtures/project-generator-templates/app/dot-gitignore new file mode 100644 index 0000000..e69de29 diff --git a/contract/v1/fixtures/project-generator-templates/main/Main.java b/contract/v1/fixtures/project-generator-templates/main/Main.java new file mode 100644 index 0000000..a900949 --- /dev/null +++ b/contract/v1/fixtures/project-generator-templates/main/Main.java @@ -0,0 +1,3 @@ +package {{ field.groupId }}; + +class Main {} diff --git a/contract/v1/fixtures/project-generator.plugin.json b/contract/v1/fixtures/project-generator.plugin.json new file mode 100644 index 0000000..548eed5 --- /dev/null +++ b/contract/v1/fixtures/project-generator.plugin.json @@ -0,0 +1,83 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.project-generator", + "displayName": "Project Generator Fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": {"projectGenerators": 1}, + "vendor": {"id": "org.example", "displayName": "Example", "signatureRequirement": "publisherPackage"}, + "permissions": ["project.generate"], + "activationEvents": [], + "projectGenerators": [ + { + "id": "app", + "displayName": "App", + "section": "project", + "fields": [ + {"id": "buildSystem", "type": "enum", "title": "Build system", "presentation": "segmented", + "options": [{"value": "none", "title": "None"}, {"value": "maven", "title": "Maven"}]}, + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java", "minimumVersion": "17"}, + {"id": "sample", "type": "boolean", "title": "Add sample code", "default": true}, + {"id": "groupId", "type": "text", "title": "Group", "advanced": true, "default": "com.example", + "pattern": "[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*", + "visibleWhen": {"field": "buildSystem", "equals": "maven"}} + ], + "requirements": [ + {"languageID": "java"}, + {"buildSystemID": "maven", "when": {"field": "buildSystem", "equals": "maven"}} + ], + "steps": [ + {"kind": "files", "source": "project-generator-templates/app"}, + {"kind": "files", "source": "project-generator-templates/main", + "target": "src/${field.groupId|package|path}", + "when": [{"field": "buildSystem", "equals": "maven"}, {"field": "sample", "equals": true}]} + ], + "openFiles": [ + {"path": "src/${field.groupId|path}/Main.java", + "when": [{"field": "buildSystem", "equals": "maven"}, {"field": "sample", "equals": true}]} + ] + }, + { + "id": "archetype", + "displayName": "Archetype", + "section": "generator", + "fields": [ + {"id": "jdk", "type": "runtime", "title": "JDK", "runtime": "java"}, + {"id": "artifactId", "type": "text", "title": "Artifact", "default": "${project.name|lower}", + "pattern": "[a-z0-9-]+"}, + {"id": "version", "type": "text", "title": "Archetype version", "optional": true, "pattern": "[0-9.]+"} + ], + "steps": [ + {"kind": "command", "command": "mvn", "mayUseNetwork": true, + "arguments": [ + "-B", "archetype:generate", + "-DartifactId=${field.artifactId}", + {"value": "-DarchetypeVersion=${field.version}", "when": {"field": "version", "notEquals": ""}} + ], + "environment": {"JAVA_HOME": "${field.jdk}"}, + "collect": "${field.artifactId}"} + ] + }, + { + "id": "initializr", + "displayName": "Initializr", + "section": "generator", + "order": 5, + "fields": [ + {"id": "serverUrl", "type": "text", "title": "Server URL", "default": "https://start.example.com", + "pattern": "https?://[a-z0-9.:-]+"}, + {"id": "dependencies", "type": "multiSelect", "title": "Dependencies", + "options": [{"value": "web", "title": "Web"}, {"value": "jpa", "title": "JPA"}]}, + {"id": "bootVersion", "type": "text", "title": "Boot", "optional": true, "pattern": "[0-9.]+"} + ], + "steps": [ + {"kind": "archive", "url": "${field.serverUrl}/starter.zip", + "query": [ + {"name": "name", "value": "${project.name}"}, + {"name": "bootVersion", "value": "${field.bootVersion}", "omitEmpty": true}, + {"name": "dependencies", "value": "${field.dependencies}", "omitEmpty": true} + ]} + ] + } + ] +} diff --git a/contract/v1/fixtures/project-plans.json b/contract/v1/fixtures/project-plans.json new file mode 100644 index 0000000..b4a16fe --- /dev/null +++ b/contract/v1/fixtures/project-plans.json @@ -0,0 +1,360 @@ +{ + "schemaVersion": 1, + "description": "Golden answers of the project.* commands for project-generator.plugin.json. generationPlan cases read templates with the fixtures directory as the package path.", + "cases": [ + { + "name": "form defaults", + "operation": "generatorForm", + "generatorID": "app", + "form": { + "projectName": "demo" + }, + "expected": { + "status": "ok", + "form": { + "projectName": { + "problem": null + }, + "fields": [ + { + "id": "buildSystem", + "visible": true, + "value": "none" + }, + { + "id": "jdk", + "visible": true, + "value": "", + "problem": "required" + }, + { + "id": "sample", + "visible": true, + "value": true + }, + { + "id": "groupId", + "visible": false, + "value": "com.example" + } + ], + "activeRequirements": [ + 0 + ], + "valid": false + } + } + }, + { + "name": "form shows maven fields and requirements", + "operation": "generatorForm", + "generatorID": "app", + "form": { + "projectName": "demo", + "values": { + "buildSystem": "maven", + "groupId": "Bad Group" + }, + "edited": [ + "groupId" + ], + "runtimes": { + "jdk": { + "home": "/opt/jdk-21", + "version": "21.0.4" + } + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": { + "problem": null + }, + "fields": [ + { + "id": "buildSystem", + "visible": true, + "value": "maven" + }, + { + "id": "jdk", + "visible": true, + "value": "/opt/jdk-21" + }, + { + "id": "sample", + "visible": true, + "value": true + }, + { + "id": "groupId", + "visible": true, + "value": "Bad Group", + "problem": "invalidFormat" + } + ], + "activeRequirements": [ + 0, + 1 + ], + "valid": false + } + } + }, + { + "name": "form derives a default from the project name", + "operation": "generatorForm", + "generatorID": "archetype", + "form": { + "projectName": "My-App", + "runtimes": { + "jdk": { + "home": "/opt/jdk-21", + "version": "21.0.4" + } + } + }, + "expected": { + "status": "ok", + "form": { + "projectName": { + "problem": null + }, + "fields": [ + { + "id": "jdk", + "visible": true, + "value": "/opt/jdk-21" + }, + { + "id": "artifactId", + "visible": true, + "value": "my-app" + }, + { + "id": "version", + "visible": true, + "value": "" + } + ], + "activeRequirements": [], + "valid": true + } + } + }, + { + "name": "plan writes templates", + "operation": "generationPlan", + "generatorID": "app", + "form": { + "projectName": "demo", + "values": { + "buildSystem": "maven" + }, + "runtimes": { + "jdk": { + "home": "/opt/jdk-21", + "version": "21.0.4" + } + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "demo", + "steps": [ + { + "kind": "writeFiles", + "files": [ + { + "path": "README.md", + "contents": "# demo\n\nBuild files keep ${placeholders} and {{other}} text unchanged.\n", + "executable": false + }, + { + "path": ".gitignore", + "contents": "", + "executable": false + } + ] + }, + { + "kind": "writeFiles", + "files": [ + { + "path": "src/com/example/Main.java", + "contents": "package com.example;\n\nclass Main {}\n", + "executable": false + } + ] + } + ], + "openFiles": [ + "src/com/example/Main.java" + ], + "runtimes": [ + { + "field": "jdk", + "runtime": "java", + "home": "/opt/jdk-21", + "version": "21.0.4" + } + ], + "usesNetwork": false + } + } + }, + { + "name": "plan rejects an old runtime", + "operation": "generationPlan", + "generatorID": "app", + "form": { + "projectName": "demo", + "runtimes": { + "jdk": { + "home": "/opt/jdk-11", + "version": "11.0.2" + } + } + }, + "expected": { + "status": "rejected", + "error": { + "code": "plugin.project.invalidInput", + "message": "The value of jdk is not accepted (runtimeTooOld).", + "field": "values.jdk" + } + } + }, + { + "name": "plan runs a scaffolding command", + "operation": "generationPlan", + "generatorID": "archetype", + "form": { + "projectName": "My-App", + "values": { + "version": "1.5" + }, + "edited": [ + "version" + ], + "runtimes": { + "jdk": { + "home": "/opt/jdk-21", + "version": "21.0.4" + } + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "My-App", + "steps": [ + { + "kind": "command", + "command": "mvn", + "arguments": [ + "-B", + "archetype:generate", + "-DartifactId=my-app", + "-DarchetypeVersion=1.5" + ], + "environment": { + "JAVA_HOME": "/opt/jdk-21" + }, + "collect": "my-app", + "mayUseNetwork": true, + "timeoutSeconds": 600 + } + ], + "openFiles": [], + "runtimes": [ + { + "field": "jdk", + "runtime": "java", + "home": "/opt/jdk-21", + "version": "21.0.4" + } + ], + "usesNetwork": true + } + } + }, + { + "name": "plan downloads an archive", + "operation": "generationPlan", + "generatorID": "initializr", + "form": { + "projectName": "my app", + "values": { + "dependencies": [ + "jpa", + "web" + ] + } + }, + "expected": { + "status": "ok", + "plan": { + "projectName": "my app", + "steps": [ + { + "kind": "archive", + "url": "https://start.example.com/starter.zip?name=my+app&dependencies=web%2Cjpa", + "host": "start.example.com", + "format": "zip" + } + ], + "openFiles": [], + "runtimes": [], + "usesNetwork": true + } + } + }, + { + "name": "plan refuses a non-HTTPS server", + "operation": "generationPlan", + "generatorID": "initializr", + "form": { + "projectName": "demo", + "values": { + "serverUrl": "http://start.example.com" + }, + "edited": [ + "serverUrl" + ] + }, + "expected": { + "status": "rejected", + "error": { + "code": "plugin.project.invalidURL", + "message": "http://start.example.com/starter.zip is not an HTTPS address of a project archive.", + "field": "projectGenerators.initializr.steps[0]" + } + } + }, + { + "name": "name accepted", + "operation": "checkName", + "projectName": "demo-app", + "expected": { + "status": "ok", + "name": { + "problem": null + } + } + }, + { + "name": "name with a slash", + "operation": "checkName", + "projectName": "a/b", + "expected": { + "status": "ok", + "name": { + "problem": "invalidFormat" + } + } + } + ] +} diff --git a/contract/v1/native-ui.schema.json b/contract/v1/native-ui.schema.json new file mode 100644 index 0000000..5e85714 --- /dev/null +++ b/contract/v1/native-ui.schema.json @@ -0,0 +1,781 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/native-ui.schema.json", + "title": "Host-rendered native plugin UI schema 1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "id", + "kind", + "nodes", + "actions", + "toolbar", + "contextMenu" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "kind": { + "enum": [ + "tree", + "list", + "form" + ] + }, + "nodes": { + "type": "array", + "maxItems": 2000, + "items": { + "$ref": "#/$defs/node" + } + }, + "source": { + "type": "object", + "additionalProperties": false, + "required": [ + "buildSystemID", + "function" + ], + "properties": { + "buildSystemID": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "function": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + } + } + }, + "actions": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "kind", + "requiresSelection" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "kind": { + "enum": [ + "refresh", + "invoke" + ] + }, + "function": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "requiresSelection": { + "type": "boolean" + }, + "icon": { + "description": "Optional host icon. Toolbar actions are icon-only; title supplies tooltip and accessibility text.", + "allOf": [ + { + "$ref": "plugin-manifest.schema.json#/$defs/uiIcon" + }, + { + "required": [ + "host" + ] + } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "invoke" + } + } + }, + "then": { + "required": [ + "function" + ] + }, + "else": { + "not": { + "required": [ + "function" + ] + }, + "properties": { + "requiresSelection": { + "const": false + } + } + } + } + ] + } + }, + "toolbar": { + "type": "array", + "uniqueItems": true, + "maxItems": 64, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + } + }, + "contextMenu": { + "type": "array", + "uniqueItems": true, + "maxItems": 64, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + } + }, + "controls": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/control" + } + }, + "layout": { + "type": "object", + "additionalProperties": false, + "required": [ + "columns" + ], + "properties": { + "columns": { + "type": "integer", + "minimum": 1, + "maximum": 12 + } + } + }, + "buttons": { + "type": "array", + "maxItems": 16, + "items": { + "$ref": "#/$defs/formButton" + } + } + }, + "$defs": { + "node": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "children": { + "type": "array", + "maxItems": 2000, + "items": { + "$ref": "#/$defs/node" + } + } + } + }, + "control": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "kind", + "label", + "value" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "kind": { + "enum": [ + "input", + "search", + "editor", + "select", + "segmented", + "checkbox", + "stepper" + ] + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "value": { + "type": [ + "string", + "boolean", + "number" + ] + }, + "placeholder": { + "type": "string", + "maxLength": 256 + }, + "enabled": { + "type": "boolean" + }, + "onCommit": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "options": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + } + }, + "span": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "info": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "rows": { + "type": "integer", + "minimum": 2, + "maximum": 12 + }, + "minimum": { + "type": "number", + "minimum": -1000000000.0, + "maximum": 1000000000.0 + }, + "maximum": { + "type": "number", + "minimum": -1000000000.0, + "maximum": 1000000000.0 + }, + "step": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1000000000.0 + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "enum": [ + "input", + "search" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "string", + "maxLength": 1024 + } + }, + "not": { + "anyOf": [ + { + "required": [ + "options" + ] + }, + { + "required": [ + "rows" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "editor" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "string", + "maxLength": 8192 + } + }, + "not": { + "anyOf": [ + { + "required": [ + "options" + ] + }, + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "onCommit" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "select" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "string" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "rows" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + }, + "required": [ + "options" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "segmented" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "string" + }, + "options": { + "maxItems": 8 + } + }, + "not": { + "anyOf": [ + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "rows" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + }, + "required": [ + "options" + ] + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "checkbox" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "boolean" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "options" + ] + }, + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "rows" + ] + }, + { + "required": [ + "minimum" + ] + }, + { + "required": [ + "maximum" + ] + }, + { + "required": [ + "step" + ] + } + ] + } + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "stepper" + ] + } + } + }, + "then": { + "properties": { + "value": { + "type": "number" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "options" + ] + }, + { + "required": [ + "placeholder" + ] + }, + { + "required": [ + "rows" + ] + } + ] + }, + "required": [ + "minimum", + "maximum", + "step" + ] + } + } + ] + }, + "formButton": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "label", + "action" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "action": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "width": { + "enum": [ + "content", + "fill" + ], + "deprecated": true, + "description": "Legacy placement hint accepted for compatibility. The host action group owns natural button widths, wrapping and spacing." + }, + "primary": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "span": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "deprecated": true, + "description": "Legacy placement hint accepted for compatibility. The host action group owns natural button widths, wrapping and spacing." + }, + "info": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "form" + } + } + }, + "then": { + "properties": { + "nodes": { + "maxItems": 0 + }, + "controls": {}, + "contextMenu": { + "maxItems": 0 + } + }, + "anyOf": [ + { + "required": [ + "controls" + ], + "properties": { + "controls": { + "minItems": 1 + } + } + }, + { + "required": [ + "buttons" + ], + "properties": { + "buttons": { + "minItems": 1 + } + } + } + ] + }, + "else": { + "properties": { + "controls": { + "maxItems": 0 + }, + "buttons": { + "maxItems": 0 + } + }, + "not": { + "required": [ + "layout" + ] + } + } + }, + { + "if": { + "not": { + "required": [ + "layout" + ] + } + }, + "then": { + "properties": { + "controls": { + "items": { + "not": { + "required": [ + "span" + ] + } + } + }, + "buttons": { + "items": { + "not": { + "required": [ + "span" + ] + } + } + } + } + } + } + ] +} diff --git a/contract/v1/plugin-manifest.schema.json b/contract/v1/plugin-manifest.schema.json index c24d138..e210b47 100644 --- a/contract/v1/plugin-manifest.schema.json +++ b/contract/v1/plugin-manifest.schema.json @@ -21,15 +21,17 @@ "minProperties": 1, "additionalProperties": false, "properties": { + "backend": { "description": "Required exactly when backend is present: the plugin's own process serving its web pages.", "enum": [1] }, "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support.", "enum": [1] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, "languages": { "enum": [1] }, - "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, and of VCS providers.", "enum": [1] }, + "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, + "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, and of the plugin backend.", "enum": [1] }, "settings": { "enum": [1] }, - "ui": { "description": "Version 2 adds web content in tool windows.", "enum": [1, 2] }, + "ui": { "description": "Version 2 adds web content; version 3 adds native controls.", "enum": [1, 2, 3] }, "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } }, @@ -45,7 +47,7 @@ }, "permissions": { "type": "array", - "items": { "enum": ["languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "ui.contribute", "ui.web"] } + "items": { "enum": ["backend.launch", "credentials.store", "languageServer.launch", "process.build", "process.run", "process.test", "process.vcs", "project.generate", "ui.contribute", "ui.web"] } }, "activationEvents": { "type": "array", @@ -70,12 +72,22 @@ "maxItems": 16, "items": { "$ref": "#/$defs/buildSystem" } }, + "backend": { + "description": "pluginAPIVersion 3, requires.backend 1: the plugin's own LSP base-protocol process. The host starts it on the first plugin-function call from one of the plugin's web pages, keeps one process per plugin independent of any workspace, and ends it when the plugin is disabled, updated, or uninstalled. Requires backend.launch and a web tool window; credentials.store lets it use the host's private credential store.", + "$ref": "#/$defs/backend" + }, "vcsProviders": { "description": "pluginAPIVersion 3: version control systems the plugin provides, each through its own LSP base-protocol process. Requires languageServer.launch, process.vcs, and one onVcsRoot: activation event per provider.", "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/vcsProvider" } }, + "projectGenerators": { + "description": "pluginAPIVersion 3 with requires.projectGenerators: entries of the New Project window. Requires the project.generate permission.", + "type": "array", + "maxItems": 16, + "items": { "$ref": "#/$defs/projectGenerator" } + }, "ui": { "type": "object", "additionalProperties": false, @@ -97,7 +109,8 @@ ], "oneOf": [ { "required": ["text"] }, - { "required": ["web"] } + { "required": ["web"] }, + { "required": ["native"] } ], "properties": { "id": { @@ -123,6 +136,7 @@ "type": "string", "maxLength": 65536 }, + "native": { "$ref": "native-ui.schema.json" }, "web": { "description": "ui 2: the plugin's own page, served from the package into a sandboxed host web view. Needs the ui.web permission.", "type": "object", @@ -198,6 +212,173 @@ } }, "$defs": { + "generatorCondition": { + "type": "object", + "additionalProperties": false, + "required": ["field"], + "properties": { + "field": { "type": "string", "description": "A field declared earlier in the same generator." }, + "equals": { "type": ["string", "boolean"] }, + "notEquals": { "type": ["string", "boolean"] }, + "oneOf": { "type": "array", "minItems": 1, "items": { "type": ["string", "boolean"] } } + }, + "oneOf": [ + { "required": ["equals"] }, + { "required": ["notEquals"] }, + { "required": ["oneOf"] } + ] + }, + "generatorConditions": { + "oneOf": [ + { "$ref": "#/$defs/generatorCondition" }, + { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/generatorCondition" } } + ] + }, + "generatorField": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "title"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][A-Za-z0-9]*$" }, + "type": { "enum": ["text", "enum", "boolean", "multiSelect", "runtime"] }, + "title": { "type": "string", "minLength": 1, "maxLength": 120 }, + "description": { "type": "string", "minLength": 1, "maxLength": 480 }, + "advanced": { "type": "boolean" }, + "visibleWhen": { "$ref": "#/$defs/generatorConditions" }, + "default": { "description": "text: a template over ${project.name} and earlier fields; enum: an option; boolean; multiSelect: options. Not allowed for runtime." }, + "options": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["value", "title"], + "properties": { + "value": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$" }, + "title": { "type": "string", "minLength": 1, "maxLength": 120 }, + "group": { "type": "string", "minLength": 1, "maxLength": 120 } + } + } + }, + "presentation": { "enum": ["segmented", "menu"] }, + "pattern": { "type": "string", "maxLength": 256 }, + "maxLength": { "type": "integer", "minimum": 1, "maximum": 1024 }, + "placeholder": { "type": "string", "minLength": 1, "maxLength": 120 }, + "optional": { "type": "boolean" }, + "runtime": { "enum": ["java"] }, + "minimumVersion": { "type": "string" }, + "maximumVersion": { "type": "string", "description": "text and enum: the highest version the value may name, a template over earlier fields such as ${field.jdk.major}." } + } + }, + "generatorConditionalValue": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["value", "when"], + "properties": { "value": { "type": "string" }, "when": { "$ref": "#/$defs/generatorConditions" } } + } + ] + }, + "generatorStep": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "source"], + "properties": { + "kind": { "const": "files" }, + "source": { "type": "string", "description": "Package-relative template directory." }, + "target": { "type": "string", "description": "Project-relative directory template." }, + "when": { "$ref": "#/$defs/generatorConditions" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "command", "mayUseNetwork"], + "properties": { + "kind": { "const": "command" }, + "command": { "type": "string", "pattern": "^[^/\\\\\\s]+$" }, + "arguments": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/generatorConditionalValue" } }, + "environment": { "type": "object", "maxProperties": 16, "additionalProperties": { "type": "string" } }, + "collect": { "type": "string" }, + "mayUseNetwork": { "type": "boolean" }, + "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 1800 }, + "when": { "$ref": "#/$defs/generatorConditions" } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "url"], + "properties": { + "kind": { "const": "archive" }, + "url": { "type": "string", "description": "https address without query or fragment; http only for loopback hosts." }, + "query": { + "type": "array", + "maxItems": 32, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "value"], + "properties": { + "name": { "type": "string", "pattern": "^[A-Za-z0-9._-]{1,120}$" }, + "value": { "type": "string" }, + "when": { "$ref": "#/$defs/generatorConditions" }, + "omitEmpty": { "type": "boolean" } + } + } + }, + "when": { "$ref": "#/$defs/generatorConditions" } + } + } + ] + }, + "projectGenerator": { + "type": "object", + "additionalProperties": false, + "required": ["id", "displayName", "section", "steps"], + "properties": { + "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "displayName": { "type": "string", "minLength": 1, "maxLength": 120 }, + "description": { "type": "string", "minLength": 1, "maxLength": 480 }, + "section": { "enum": ["project", "generator"] }, + "icon": { "$ref": "#/$defs/uiIcon" }, + "order": { "type": "integer", "minimum": 0, "maximum": 1000 }, + "fields": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/generatorField" } }, + "requirements": { + "type": "array", + "maxItems": 8, + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "languageID": { "type": "string" }, + "buildSystemID": { "type": "string" }, + "when": { "$ref": "#/$defs/generatorConditions" } + }, + "oneOf": [ { "required": ["languageID"] }, { "required": ["buildSystemID"] } ] + } + }, + "steps": { "type": "array", "minItems": 1, "maxItems": 16, "items": { "$ref": "#/$defs/generatorStep" } }, + "openFiles": { + "type": "array", + "maxItems": 8, + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "additionalProperties": false, + "required": ["path", "when"], + "properties": { "path": { "type": "string" }, "when": { "$ref": "#/$defs/generatorConditions" } } + } + ] + } + } + } + }, "uiIcon": { "type": "object", "additionalProperties": false, @@ -252,6 +433,7 @@ "terminal", "search", "refresh", + "reset", "run", "debug", "settings", @@ -459,6 +641,12 @@ } ] }, + "backend": { + "description": "Launch fields of the plugin backend. ${workspaceStateDir} is refused because the backend is not bound to a workspace.", + "type": "object", + "$ref": "#/$defs/serverProcess", + "unevaluatedProperties": false + }, "vcsProvider": { "description": "pluginAPIVersion 3: a VCS provider process (shared/contracts/vcs-provider-v1.md). Detection uses the markers only and never starts the process; capabilities are negotiated, not declared.", "type": "object", From 5da0524f4751425c216bf5162c9fc1eb1dddd22f Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 07:29:02 -0700 Subject: [PATCH 2/3] sdk/rust: official Rust SDK for plugin backends lithe-plugin-sdk implements plugin-function protocols 1 and 2 for the packaged backend a plugin declares under `backend`: framing, negotiation, functions on their own threads with progress and cancellation, runtime registration, error mapping (PluginError, internal failures, panics), and the credentials host service. It depends only on serde_json. The sample backend (notes in ${stateDir}, a secret in the host's credential store, cancellation) runs a new end-to-end scenario against Rust Core in which the test serves the host requests. CI tests on Rust 1.87 and lints on stable; sdk-rust-v* tags release a .crate and its SHA-256. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release-rust-sdk.yml | 73 ++ .github/workflows/verify.yml | 22 +- .gitignore | 1 + README.md | 14 +- scripts/sdk-e2e.mjs | 16 +- sdk/e2e/plugin_sdk/backend.rs | 361 ++++++ sdk/e2e/plugin_sdk/mod.rs | 7 +- sdk/e2e/plugin_sdk_rust.rs | 36 + sdk/rust/Cargo.lock | 113 ++ sdk/rust/Cargo.toml | 13 + sdk/rust/README.md | 177 +++ sdk/rust/examples/sample-backend/Cargo.toml | 11 + sdk/rust/examples/sample-backend/plugin.json | 30 + sdk/rust/examples/sample-backend/src/main.rs | 220 ++++ .../examples/sample-backend/ui/index.html | 23 + sdk/rust/examples/sample-backend/ui/main.js | 43 + sdk/rust/examples/sample-backend/ui/style.css | 3 + sdk/rust/plugin-sdk/Cargo.toml | 11 + sdk/rust/plugin-sdk/src/backend.rs | 1119 +++++++++++++++++ sdk/rust/plugin-sdk/src/error.rs | 240 ++++ sdk/rust/plugin-sdk/src/framing.rs | 170 +++ sdk/rust/plugin-sdk/src/lib.rs | 32 + sdk/rust/plugin-sdk/src/protocol.rs | 131 ++ sdk/rust/plugin-sdk/src/stdio.rs | 34 + sdk/rust/plugin-sdk/tests/session.rs | 517 ++++++++ 25 files changed, 3407 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release-rust-sdk.yml create mode 100644 sdk/e2e/plugin_sdk/backend.rs create mode 100644 sdk/e2e/plugin_sdk_rust.rs create mode 100644 sdk/rust/Cargo.lock create mode 100644 sdk/rust/Cargo.toml create mode 100644 sdk/rust/README.md create mode 100644 sdk/rust/examples/sample-backend/Cargo.toml create mode 100644 sdk/rust/examples/sample-backend/plugin.json create mode 100644 sdk/rust/examples/sample-backend/src/main.rs create mode 100644 sdk/rust/examples/sample-backend/ui/index.html create mode 100644 sdk/rust/examples/sample-backend/ui/main.js create mode 100644 sdk/rust/examples/sample-backend/ui/style.css create mode 100644 sdk/rust/plugin-sdk/Cargo.toml create mode 100644 sdk/rust/plugin-sdk/src/backend.rs create mode 100644 sdk/rust/plugin-sdk/src/error.rs create mode 100644 sdk/rust/plugin-sdk/src/framing.rs create mode 100644 sdk/rust/plugin-sdk/src/lib.rs create mode 100644 sdk/rust/plugin-sdk/src/protocol.rs create mode 100644 sdk/rust/plugin-sdk/src/stdio.rs create mode 100644 sdk/rust/plugin-sdk/tests/session.rs diff --git a/.github/workflows/release-rust-sdk.yml b/.github/workflows/release-rust-sdk.yml new file mode 100644 index 0000000..dd03051 --- /dev/null +++ b/.github/workflows/release-rust-sdk.yml @@ -0,0 +1,73 @@ +# Releases the official Rust plugin SDK (`sdk/rust/plugin-sdk`) through its +# own tag series (`sdk-rust-v`). Plugin repositories either depend +# on the tag through Git, which their Cargo.lock pins to the commit, or pin +# `lithe-plugin-sdk-rust-.crate` by its `.sha256` and vendor the +# unpacked crate; the SDK is not published to crates.io. +name: Release Rust Plugin SDK + +on: + push: + tags: + - "sdk-rust-v*" + +permissions: + contents: read + +concurrency: + group: release-rust-plugin-sdk-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Release ${{ github.ref_name }} + if: github.repository == 'Lithe-IDEA/lithe-plugin-docs' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + + steps: + - name: Check out source + uses: actions/checkout@v7 + + # The release is tested on the oldest supported Rust. + - uses: dtolnay/rust-toolchain@1.87 + + - name: Require the tag to match the SDK version + id: version + env: + TAG: ${{ github.ref_name }} + run: | + version=$(cargo +1.87 metadata --locked --no-deps --format-version 1 --manifest-path sdk/rust/Cargo.toml \ + | jq -r '.packages[] | select(.name == "lithe-plugin-sdk") | .version') + test "$TAG" = "sdk-rust-v$version" || { echo "Tag $TAG does not match sdk/rust version $version"; exit 1; } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Test + run: cargo +1.87 test --locked --manifest-path sdk/rust/Cargo.toml + + # `cargo package` only builds the archive; `publish = false` keeps the + # SDK off crates.io. The repository LICENSE is copied in so the archive + # carries its own license text. + - name: Collect the release assets + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + cp LICENSE sdk/rust/plugin-sdk/LICENSE + cargo +1.87 package --locked --allow-dirty --manifest-path sdk/rust/Cargo.toml -p lithe-plugin-sdk + mkdir -p dist + asset="lithe-plugin-sdk-rust-$VERSION.crate" + cp "sdk/rust/target/package/lithe-plugin-sdk-$VERSION.crate" "dist/$asset" + tar -tzf "dist/$asset" | sort + (cd dist && sha256sum "$asset" | cut -d' ' -f1 > "$asset.sha256") + + - name: Publish the SDK release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.version }} + run: | + asset="lithe-plugin-sdk-rust-$VERSION.crate" + gh release create "$TAG" "dist/$asset" "dist/$asset.sha256" \ + --title "Rust plugin SDK $VERSION" \ + --notes "Official Rust plugin SDK $VERSION for Lithe plugin backends. Requires Rust 1.87 or newer and depends only on serde_json. Depend on the tag through Git, or pin the archive by its SHA-256 (\`$asset.sha256\`) and vendor the unpacked crate; see sdk/rust/README.md." diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index e7bf5cd..83d19d0 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -85,6 +85,25 @@ jobs: JAVA8: ${{ steps.java8.outputs.path }}/bin/java run: mvn -B -ntp -f sdk/java/pom.xml verify -Djvm="$JAVA8" + sdk-rust: + name: Rust SDK + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + # The SDK supports Rust 1.87 and newer: the tests run on that release, + # and formatting and lints on the current stable toolchain. + - uses: dtolnay/rust-toolchain@1.87 + - name: Test on the oldest supported Rust + run: cargo +1.87 test --locked --manifest-path sdk/rust/Cargo.toml + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - name: Check formatting and lints + run: | + cargo fmt --manifest-path sdk/rust/Cargo.toml --all -- --check + cargo clippy --locked --manifest-path sdk/rust/Cargo.toml --all-targets -- -D warnings + sdk-e2e: name: SDK end-to-end with Rust Core runs-on: ubuntu-latest @@ -113,7 +132,8 @@ jobs: ~/.cargo/registry ~/.cargo/git .artifacts/lithe/rust/target - key: sdk-e2e-${{ runner.os }}-${{ hashFiles('contract.lock.json') }} + sdk/rust/target + key: sdk-e2e-${{ runner.os }}-${{ hashFiles('contract.lock.json', 'sdk/rust/Cargo.lock') }} restore-keys: sdk-e2e-${{ runner.os }}- - name: Run against the pinned Lithe commit diff --git a/.gitignore b/.gitignore index a26988c..c2afc66 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ *.tmp .artifacts/ sdk/java/**/target/ +sdk/rust/target/ diff --git a/README.md b/README.md index ed222d2..514fef1 100644 --- a/README.md +++ b/README.md @@ -94,16 +94,17 @@ Schema 只检查 JSON 结构。ID 引用、权限、激活事件、模板变量 ## 插件后端 SDK(可选) -只用语言服务器、运行和测试的插件不需要后端。需要构建后端(`buildProviders`)或插件函数时,用官方 SDK 写:协议、取消、进程清理和错误处理由 SDK 负责,你只写处理函数。 +只用语言服务器、运行和测试的插件不需要后端。需要构建后端(`buildProviders`)、插件函数,或给插件自己的网页配一个后端进程(`backend`)时,用官方 SDK 写:协议、取消、进程清理和错误处理由 SDK 负责,你只写处理函数。 | SDK | 运行时 | Release 标签 | 下载内容 | 怎么放进插件 | | --- | --- | --- | --- | --- | | [Node.js](sdk/node/README.md) | Node.js 20.3+ | `sdk-node-v<版本>` | `lithe-plugin-sdk-node-<版本>.tgz` 和 `.sha256` | 解压后把 `package/` 复制进插件包,用相对路径导入 | | [Java](sdk/java/README.md) | Java 8+ | `sdk-java-v<版本>` | `lithe-plugin-sdk-java-<版本>.jar` 和 `.sha256` | shade 进后端 JAR | +| [Rust](sdk/rust/README.md) | Rust 1.87+ | `sdk-rust-v<版本>` | `lithe-plugin-sdk-rust-<版本>.crate` 和 `.sha256` | 用 Git 依赖锁定标签(`Cargo.lock` 记录 commit),或解压后作为 `path` 依赖;编译成每个平台各一份的插件后端可执行文件 | -两个 SDK 的源码在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases) 里,不在 npm 或 Maven 仓库上。Java SDK 0.1.0 是搬到这里之前从 Lithe 主仓库发布的,[那个 Release](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0) 继续可用,已经锁定它的插件不用改。 +SDK 的源码都在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases) 里,不在 npm、Maven 或 crates.io 上。Rust SDK 目前只实现插件后端(`backend`):插件函数和宿主凭据服务;构建后端和 VCS 后端请用 Node.js 或 Java SDK。Java SDK 0.1.0 是搬到这里之前从 Lithe 主仓库发布的,[那个 Release](https://github.com/1lck/Lithe-IDEA/releases/tag/sdk-java-v0.1.0) 继续可用,已经锁定它的插件不用改。 -规则只有一条:**在构建时下载,并按你锁定的 SHA-256 校验;运行时不下载任何东西。** 插件包安装后没有 `npm install` 步骤,也必须能离线运行。 +规则只有一条:**在构建时下载,并按你锁定的 SHA-256(或 Git commit)校验;运行时不下载任何东西。** 插件包安装后没有 `npm install` 步骤,也必须能离线运行。 [Maven 插件](https://github.com/Lithe-IDEA/lithe-plugin-maven) 的 `sdk.lock.json` 是锁定 Java SDK 的现成例子。 @@ -169,12 +170,13 @@ node scripts/contract.mjs sync --revision <主仓库的完整 commit> | --- | --- | | `node --test sdk/node/test/*.test.mjs` | Node.js SDK 的单元测试 | | `mvn -B -ntp -f sdk/java/pom.xml verify -Djvm=` | Java SDK 的单元测试;`-Djvm` 指定运行测试的 Java(CI 用 Java 8) | -| `node scripts/sdk-e2e.mjs` | 端到端测试:两个示例后端接入真实的 Lithe Rust Core,跑 `sdk/e2e` 里的同一套场景 | +| `cargo test --locked --manifest-path sdk/rust/Cargo.toml` | Rust SDK 的单元测试(CI 用 Rust 1.87,另用 stable 跑 `cargo fmt` 和 `cargo clippy`) | +| `node scripts/sdk-e2e.mjs` | 端到端测试:各 SDK 的示例后端接入真实的 Lithe Rust Core。Node.js 和 Java 跑 `sdk/e2e` 里同一套构建后端场景,Rust 跑插件后端场景(`plugin_sdk/backend.rs`),测试代替宿主应答凭据请求 | | `node scripts/sdk-e2e.mjs --ref preview` | 同上,但用主仓库 `preview` 分支的最新 Core | -端到端测试需要 Rust、Node.js 20.3+、Java 和 Maven。Rust Core 在主仓库,脚本会把锁文件锁定的那个主仓库 commit 稀疏检出到 `.artifacts/lithe`(只取 `rust/` 和 `shared/`),把 `sdk/e2e` 复制进它的 `rust/lithe-core/tests/` 再运行,所以 SDK 测的是和契约副本同一个版本的 Core。`LITHE_PLUGIN_SDK_JAVA_RUNTIME` 指定运行 Java 示例的 Java;已有主仓库本地克隆时可以加 `--source <克隆目录>`。CI 每次提交跑锁定版本,每周一再跑一次主仓库 `preview` 的最新版本,Core 改动影响 SDK 时会提前发现。 +端到端测试需要 Rust、Node.js 20.3+、Java 和 Maven。Rust Core 在主仓库,脚本会把锁文件锁定的那个主仓库 commit 稀疏检出到 `.artifacts/lithe`(只取 `rust/` 和 `shared/`),把 `sdk/e2e` 复制进它的 `rust/lithe-core/tests/` 再运行,所以 SDK 测的是和契约副本同一个版本的 Core。`LITHE_PLUGIN_SDK_JAVA_RUNTIME` 指定运行 Java 示例的 Java,`LITHE_PLUGIN_SDK_RUST_SAMPLE` 可以指定已编译好的 Rust 示例后端(否则脚本用 Cargo 编译);已有主仓库本地克隆时可以加 `--source <克隆目录>`。CI 每次提交跑锁定版本,每周一再跑一次主仓库 `preview` 的最新版本,Core 改动影响 SDK 时会提前发现。 -发版:先改版本号(Node.js 改 `sdk/node/package.json`;Java 运行 `mvn -f sdk/java/pom.xml versions:set -DnewVersion=<版本> -DgenerateBackupPoms=false`,三个 pom 一起改),合并后推送对应标签 `sdk-node-v<版本>` 或 `sdk-java-v<版本>`。发版工作流会检查标签和版本一致,测试通过后把包和 `.sha256` 发到本仓库的 Releases。 +发版:先改版本号(Node.js 改 `sdk/node/package.json`;Java 运行 `mvn -f sdk/java/pom.xml versions:set -DnewVersion=<版本> -DgenerateBackupPoms=false`,三个 pom 一起改;Rust 改 `sdk/rust/Cargo.toml` 的 `workspace.package.version` 并更新 `Cargo.lock`),合并后推送对应标签 `sdk-node-v<版本>`、`sdk-java-v<版本>` 或 `sdk-rust-v<版本>`。发版工作流会检查标签和版本一致,测试通过后把包和 `.sha256` 发到本仓库的 Releases。 ## 许可证 diff --git a/scripts/sdk-e2e.mjs b/scripts/sdk-e2e.mjs index ff66d7e..98f859a 100644 --- a/scripts/sdk-e2e.mjs +++ b/scripts/sdk-e2e.mjs @@ -12,6 +12,8 @@ // The Node.js sample runs on the node running this script. The Java sample // runs on LITHE_PLUGIN_SDK_JAVA_RUNTIME, or `java` on PATH, and is built with // Maven unless LITHE_PLUGIN_SDK_JAVA_SAMPLE names a built sample-build.jar. +// The Rust sample backend is built with Cargo unless +// LITHE_PLUGIN_SDK_RUST_SAMPLE names a built sample-backend executable. import { spawn } from 'node:child_process'; import { access, copyFile, mkdir, readdir } from 'node:fs/promises'; import { join, resolve } from 'node:path'; @@ -20,7 +22,7 @@ import { readLock, repository } from './contract.mjs'; const work = join(repository, '.artifacts', 'lithe'); const scenario = join(repository, 'sdk', 'e2e'); -const tests = ['plugin_sdk_node', 'plugin_sdk_java']; +const tests = ['plugin_sdk_node', 'plugin_sdk_java', 'plugin_sdk_rust']; function run(command, args, options = {}) { return new Promise((resolvePromise, reject) => { @@ -87,6 +89,16 @@ async function javaSample() { return jar; } +async function rustSample() { + if (process.env.LITHE_PLUGIN_SDK_RUST_SAMPLE) return resolve(process.env.LITHE_PLUGIN_SDK_RUST_SAMPLE); + const manifest = join(repository, 'sdk/rust/Cargo.toml'); + await run('cargo', ['build', '--locked', '--quiet', '--manifest-path', manifest, '-p', 'sample-backend']); + const executable = join(repository, 'sdk/rust/target/debug', + process.platform === 'win32' ? 'sample-backend.exe' : 'sample-backend'); + await access(executable); + return executable; +} + async function main() { const { values } = parseArgs({ options: { ref: { type: 'string' }, source: { type: 'string' } } }); const lock = await readLock(); @@ -95,6 +107,7 @@ async function main() { const java = process.env.LITHE_PLUGIN_SDK_JAVA_RUNTIME || await which('java'); const jar = await javaSample(); + const rust = await rustSample(); const revision = await checkoutCore(source, ref); console.log(`Running the SDK end-to-end scenario against Lithe ${revision}`); await copyScenario(join(work, 'rust/lithe-core/tests')); @@ -107,6 +120,7 @@ async function main() { LITHE_PLUGIN_SDK_NODE: process.execPath, LITHE_PLUGIN_SDK_JAVA: java, LITHE_PLUGIN_SDK_JAVA_SAMPLE: jar, + LITHE_PLUGIN_SDK_RUST_SAMPLE: rust, }, }); } diff --git a/sdk/e2e/plugin_sdk/backend.rs b/sdk/e2e/plugin_sdk/backend.rs new file mode 100644 index 0000000..da6a71a --- /dev/null +++ b/sdk/e2e/plugin_sdk/backend.rs @@ -0,0 +1,361 @@ +//! End-to-end scenario of a plugin backend (`backend` in `plugin.json`), +//! run against Rust Core through the same JSON commands a host sends. +//! +//! The test plays the host: it expands the backend launch plan, starts the +//! sample under the plugin's backend provider ID with the granted host +//! services, and answers Core's `hostRequest` events from an in-memory +//! credential store the way the macOS host answers them from the Keychain. +//! The scenario drives functions with progress, a plugin failure, an +//! unexpected failure, cancellation, runtime registration, credentials, and +//! state across a restart, where the backend is granted no host service. + +use super::{core, listing, Fixture, Session}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; +use std::time::Instant; + +/// Runs the scenario against the installed package at `package`, whose +/// `plugin.json` declares the sample backend. +pub fn run_backend_scenario(package: &Path) { + let manifest: Value = + serde_json::from_slice(&fs::read(package.join("plugin.json")).expect("sample manifest")) + .expect("sample manifest JSON"); + let plugin_id = manifest["id"].as_str().expect("plugin ID").to_string(); + let package_before = listing(package); + let validated = core( + "plugins.declarative.validate", + json!({ "manifest": manifest }), + ); + assert_eq!(validated["status"], "ok", "{validated}"); + + let fixture = Fixture::new("backend"); + let state = fixture.root.join("state"); + let plan = launch_plan(&manifest, package, &state); + assert_eq!(plan["backend"]["hostServices"], json!(["credentials"])); + assert_eq!(plan["requiredDirectories"], json!(["state"])); + fs::create_dir_all(&state).expect("host state directory"); + + let mut session = start(&plugin_id, package, &plan, &state); + assert_eq!( + session.functions(), + [ + "fail.unexpectedly", + "note.read", + "note.save", + "secret.check", + "secret.forget", + "secret.remember", + "work.wait" + ] + ); + + // A function with progress, which registers note.clear on first use. + let saved = session.call( + "save", + "note.save", + json!({ "name": "first", "text": "héllo" }), + ); + assert_eq!( + saved["result"]["value"], + json!({ "name": "first", "bytes": 6 }), + "{saved}" + ); + let progress: Vec<_> = session + .events + .iter() + .filter(|event| event["type"] == "requestProgress" && event["operationId"] == "save") + .map(|event| event["result"]["message"].clone()) + .collect(); + assert_eq!(progress, ["Saving", "Saved"]); + session.await_event("note.clear to be registered", |event| { + event["type"] == "functionsChanged" + && event["result"]["functions"] + .as_array() + .is_some_and(|functions| functions.contains(&json!("note.clear"))) + }); + let read = session.call("read", "note.read", json!({ "name": "first" })); + assert_eq!(read["result"]["value"], json!({ "text": "héllo" })); + + // A plugin failure keeps Lithe's stable code and forwards the plugin's own. + let missing = session.call("missing", "note.read", json!({ "name": "other" })); + assert_eq!( + missing["error"]["code"], "plugin.function.failed", + "{missing}" + ); + assert_eq!(missing["error"]["pluginErrorCode"], "sample.noteMissing"); + + // An unexpected failure reaches the host without its details. + let failed = session.call("fail", "fail.unexpectedly", json!({})); + assert_eq!( + failed["error"]["code"], "plugin.function.failed", + "{failed}" + ); + assert!(failed["error"].get("pluginErrorCode").is_none(), "{failed}"); + assert!( + !failed.to_string().contains(&*state.to_string_lossy()), + "a machine path reached the host: {failed}" + ); + + // Credentials travel as host requests and never touch the state directory. + let mut store = BTreeMap::new(); + let remembered = serve_host( + &mut session, + &mut store, + "remember", + "secret.remember", + json!({ "key": "sample:password", "secret": "s3cr3t" }), + ); + assert_eq!(remembered["result"]["value"], json!({}), "{remembered}"); + assert_eq!( + store, + BTreeMap::from([("sample:password".to_string(), "s3cr3t".to_string())]) + ); + for (operation, secret, matches) in [("check", "s3cr3t", true), ("check-wrong", "nope", false)] + { + let checked = serve_host( + &mut session, + &mut store, + operation, + "secret.check", + json!({ "key": "sample:password", "secret": secret }), + ); + assert_eq!( + checked["result"]["value"], + json!({ "stored": true, "matches": matches }), + "{checked}" + ); + } + let forgotten = serve_host( + &mut session, + &mut store, + "forget", + "secret.forget", + json!({ "key": "sample:password" }), + ); + assert_eq!(forgotten["result"]["value"], Value::Null, "{forgotten}"); + assert!(store.is_empty()); + let requests: Vec<_> = session + .events + .iter() + .filter(|event| event["type"] == "hostRequest") + .map(|event| (event["method"].clone(), event["result"]["service"].clone())) + .collect(); + assert_eq!(requests.len(), 4, "{requests:?}"); + assert!(requests.iter().all(|(_, service)| service == "credentials")); + + // Cancelling a running call reaches the handler. + session.invoke("wait", "work.wait", json!({})); + wait_for_file(&mut session, &state.join("waiting")); + core( + "lsp.cancelOperation", + json!({ "sessionId": session.id, "operationId": "wait" }), + ); + assert_eq!( + session.completion("wait")["error"]["code"], + "requestCancelled" + ); + wait_for_file(&mut session, &state.join("cancelled")); + + // State survives a restart. Without credentials.store the host grants no + // host service, and Core refuses the request before the host sees it. + session.stop(); + drop(session); + let mut ungranted = manifest.clone(); + ungranted["permissions"] = json!(["backend.launch", "ui.contribute", "ui.web"]); + let plan = launch_plan(&ungranted, package, &state); + assert_eq!(plan["backend"]["hostServices"], json!([])); + let mut restarted = start(&plugin_id, package, &plan, &state); + assert!(restarted.functions().contains(&"note.clear".to_string())); + let read = restarted.call("read-again", "note.read", json!({ "name": "first" })); + assert_eq!(read["result"]["value"], json!({ "text": "héllo" })); + let refused = restarted.call( + "remember-ungranted", + "secret.remember", + json!({ "key": "sample:password", "secret": "s3cr3t" }), + ); + assert_eq!( + refused["error"]["pluginErrorCode"], "sample.credentialsUnavailable", + "{refused}" + ); + assert!( + !restarted + .events + .iter() + .any(|event| event["type"] == "hostRequest"), + "an ungranted request reached the host" + ); + assert_eq!( + restarted.call("clear", "note.clear", json!({}))["result"]["value"], + Value::Null + ); + let cleared = restarted.call("read-cleared", "note.read", json!({ "name": "first" })); + assert_eq!(cleared["error"]["pluginErrorCode"], "sample.noteMissing"); + restarted.stop(); + drop(restarted); + + assert!( + listing(package) == package_before, + "the plugin package changed at runtime" + ); + let left: BTreeSet<_> = fs::read_dir(&state) + .expect("state directory") + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + left, + BTreeSet::from(["cancelled".to_string(), "waiting".to_string()]) + ); +} + +fn launch_plan(manifest: &Value, package: &Path, state: &Path) -> Value { + let plan = core( + "plugins.declarative.languageServerLaunchPlan", + json!({ + "manifest": manifest, + "backend": true, + "request": { + "pluginDirectory": package, + "stateDirectory": state, + "architecture": if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" }, + } + }), + ); + assert_eq!(plan["status"], "ok", "{plan}"); + assert_eq!(plan["plan"]["launch"]["kind"], "packaged"); + plan["plan"].clone() +} + +/// Starts the backend as the host does: the packaged executable, under the +/// plugin's backend provider ID, with the state directory as root and +/// working directory and the plan's host services. +fn start(plugin_id: &str, package: &Path, plan: &Value, state: &Path) -> Session { + let executable = package.join(plan["launch"]["path"].as_str().expect("packaged path")); + let started = core( + "lsp.startServer", + json!({ + "providerId": format!("pluginBackend:{plugin_id}"), + "pluginId": plugin_id, + "executablePath": executable, + "arguments": plan["arguments"], + "rootUri": format!("file://{}/", state.display()), + "workingDirectory": state, + "hostServices": plan["backend"]["hostServices"], + "initializeTimeoutMilliseconds": super::WAIT.as_millis() as u64, + "requestTimeoutMilliseconds": super::WAIT.as_millis() as u64, + "shutdownTimeoutMilliseconds": 5000, + }), + ); + let mut session = Session { + id: started["sessionId"] + .as_str() + .expect("session ID") + .to_string(), + plugin_id: plugin_id.to_string(), + events: Vec::new(), + }; + session.await_event("the backend to become ready", |event| { + event["type"] == "stateChanged" && event["state"] == "ready" + }); + session +} + +/// Invokes `function` and answers every host request of the call from +/// `store` until the call ends. +fn serve_host( + session: &mut Session, + store: &mut BTreeMap, + operation: &str, + function: &str, + params: Value, +) -> Value { + let request_id = |event: &Value| { + event["result"]["hostRequestID"] + .as_str() + .map(str::to_string) + }; + let mut answered: BTreeSet<_> = session + .events + .iter() + .filter(|event| event["type"] == "hostRequest") + .filter_map(request_id) + .collect(); + session.invoke(operation, function, params); + let deadline = Instant::now() + super::WAIT; + loop { + let pending: Vec<_> = session + .events + .iter() + .filter(|event| { + event["type"] == "hostRequest" + && request_id(event).is_some_and(|id| !answered.contains(&id)) + }) + .cloned() + .collect(); + for request in pending { + let id = request_id(&request).expect("host request ID"); + let params = &request["result"]["params"]; + let key = params["key"].as_str().expect("credential key").to_string(); + let result = match request["method"].as_str() { + Some("lithe/host/credentials/get") => { + json!({ "secret": store.get(&key) }) + } + Some("lithe/host/credentials/set") => { + store.insert(key, params["secret"].as_str().expect("secret").to_string()); + Value::Null + } + Some("lithe/host/credentials/delete") => { + store.remove(&key); + Value::Null + } + other => panic!("unexpected host request {other:?}"), + }; + core( + "lsp.hostResponse", + json!({ "sessionId": session.id, "hostRequestID": id, "result": result }), + ); + answered.insert(id); + } + if let Some(completed) = session + .events + .iter() + .find(|event| event["operationId"] == operation && event["type"] == "requestCompleted") + { + return completed.clone(); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!( + !remaining.is_zero(), + "timed out waiting for {operation}; events: {:#?}", + session.events + ); + let drained = core( + "lsp.waitEvents", + json!({ "sessionId": session.id, "timeoutMilliseconds": remaining.as_millis().min(200) as u64 }), + ); + session + .events + .extend(drained["events"].as_array().cloned().unwrap_or_default()); + } +} + +/// Waits for the backend to create `path`, draining events meanwhile. The +/// file is the only signal a handler blocked in the backend process gives. +fn wait_for_file(session: &mut Session, path: &Path) { + let deadline = Instant::now() + super::WAIT; + while !path.exists() { + assert!( + Instant::now() < deadline, + "the backend never created {}", + path.display() + ); + let drained = core( + "lsp.waitEvents", + json!({ "sessionId": session.id, "timeoutMilliseconds": 50 }), + ); + session + .events + .extend(drained["events"].as_array().cloned().unwrap_or_default()); + } +} diff --git a/sdk/e2e/plugin_sdk/mod.rs b/sdk/e2e/plugin_sdk/mod.rs index a3664f8..f725c8b 100644 --- a/sdk/e2e/plugin_sdk/mod.rs +++ b/sdk/e2e/plugin_sdk/mod.rs @@ -1,6 +1,6 @@ //! End-to-end scenario shared by the official plugin SDKs (`sdk/node`, //! `sdk/java`), run against Rust Core through the same JSON commands a host -//! sends. +//! sends. The plugin backend scenario of `sdk/rust` is in `backend.rs`. //! //! Each SDK's sample backend is launched exactly as a host launches a build //! provider: Core validates its manifest and expands the launch plan, the @@ -18,6 +18,7 @@ // Each test binary includes this module and uses only the helpers its SDK needs. #![allow(dead_code)] +pub mod backend; pub mod vcs; use lithe_core::execute_json; @@ -44,7 +45,9 @@ fn core(command: &str, payload: Value) -> Value { fn repository_root() -> PathBuf { let root = std::env::var_os("LITHE_PLUGIN_SDK_ROOT") .expect("set LITHE_PLUGIN_SDK_ROOT to the lithe-plugin-docs checkout"); - PathBuf::from(root).canonicalize().expect("lithe-plugin-docs checkout") + PathBuf::from(root) + .canonicalize() + .expect("lithe-plugin-docs checkout") } /// Every file of a directory with its contents, to prove the package is unchanged. diff --git a/sdk/e2e/plugin_sdk_rust.rs b/sdk/e2e/plugin_sdk_rust.rs new file mode 100644 index 0000000..9f8c880 --- /dev/null +++ b/sdk/e2e/plugin_sdk_rust.rs @@ -0,0 +1,36 @@ +//! End-to-end check of the official Rust plugin SDK (`sdk/rust`) against Rust +//! Core, with the plugin backend scenario in `plugin_sdk/backend.rs`. +//! +//! The sample is installed the way a plugin ships: `plugin.json`, the +//! backend executable at its packaged path, and the web page that calls it. +//! It runs only when `LITHE_PLUGIN_SDK_RUST_SAMPLE` names the built +//! `sample-backend` executable; `scripts/sdk-e2e.mjs` sets it. + +mod plugin_sdk; + +#[test] +fn the_rust_sdk_sample_backend_serves_functions_and_host_credentials() { + let Some(executable) = std::env::var_os("LITHE_PLUGIN_SDK_RUST_SAMPLE") else { + eprintln!("skipped: set LITHE_PLUGIN_SDK_RUST_SAMPLE to run the Rust SDK end-to-end check"); + return; + }; + let sample = |file: &str| { + ( + file.to_string(), + plugin_sdk::repository_path(&format!("sdk/rust/examples/sample-backend/{file}")), + ) + }; + let files = [ + sample("plugin.json"), + sample("ui/index.html"), + sample("ui/main.js"), + sample("ui/style.css"), + ("bin/sample-backend".to_string(), executable.into()), + ]; + let files: Vec<_> = files + .iter() + .map(|(destination, source)| (destination.as_str(), source.clone())) + .collect(); + let package = plugin_sdk::TemporaryPackage::new("rust", &files); + plugin_sdk::backend::run_backend_scenario(&package.root); +} diff --git a/sdk/rust/Cargo.lock b/sdk/rust/Cargo.lock new file mode 100644 index 0000000..615f3ed --- /dev/null +++ b/sdk/rust/Cargo.lock @@ -0,0 +1,113 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lithe-plugin-sdk" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "sample-backend" +version = "0.1.0" +dependencies = [ + "lithe-plugin-sdk", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sdk/rust/Cargo.toml b/sdk/rust/Cargo.toml new file mode 100644 index 0000000..cd09681 --- /dev/null +++ b/sdk/rust/Cargo.toml @@ -0,0 +1,13 @@ +[workspace] +members = ["plugin-sdk", "examples/sample-backend"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +# std::io::pipe, used by the tests. +rust-version = "1.87" + +[workspace.dependencies] +serde_json = "1" diff --git a/sdk/rust/README.md b/sdk/rust/README.md new file mode 100644 index 0000000..acb00fb --- /dev/null +++ b/sdk/rust/README.md @@ -0,0 +1,177 @@ +# Lithe plugin backend SDK for Rust + +`lithe-plugin-sdk` is the official way to write a +[plugin backend](../../contract/v1/README.md#plugin-backend) in Rust: the one +long-lived process a plugin declares under `backend` in `plugin.json`, which +Lithe starts when one of the plugin's own web pages first calls a function. +The backend is a packaged executable, so a plugin ships one archive per +platform. It speaks the LSP base protocol on stdio and implements, from +[plugin contract v1](../../contract/v1/README.md): + +- **`litheFunctions` 1 and 2**: plugin functions with progress, + cancellation, and registration at runtime. The SDK answers the newest + version the host offers. +- **Host services** of version 2: the plugin's own credentials, kept by the + host in the platform's secret store (the Keychain on macOS). + +The SDK owns the wire format, negotiation, cancellation, and error mapping. +Your code supplies only handlers. It depends only on `serde_json`, runs +every call on a thread of its own, and needs Rust 1.87 or newer. + +Build providers and VCS providers are not part of this SDK yet; write those +with the [Node.js](../node/README.md) or [Java](../java/README.md) SDK. + +## A minimal backend + +```json +"pluginAPIVersion": 3, +"requires": { "backend": 1, "serverProcess": 1, "ui": 2 }, +"permissions": ["backend.launch", "credentials.store", "ui.contribute", "ui.web"], +"backend": { "launch": { "kind": "packaged", "path": "bin/backend" }, "arguments": ["--state", "${stateDir}"] } +``` + +```rust +use lithe_plugin_sdk::{Backend, PluginError}; +use serde_json::json; + +fn main() { + let mut backend = Backend::new("example-backend").version(env!("CARGO_PKG_VERSION")); + backend + .function("connection.test", |params, context| { + context.progress("Connecting", Some(10)); + let key = params["connection"].as_str().ok_or_else(|| { + PluginError::new("example.invalidParams", "\"connection\" must be a string.") + })?; + let password = context.credentials().get(&format!("connection:{key}"))?; + context.check_cancelled()?; + Ok(json!({ "hasPassword": password.is_some() })) + }) + .expect("a valid function ID"); + std::process::exit(backend.listen()); +} +``` + +The page calls it through the host bridge, without an `instance`: + +```js +const { value } = await lithe.request('functions.invoke', { function: 'connection.test', params: { connection: 'main' } }); +``` + +[`examples/sample-backend`](examples/sample-backend) is a complete plugin: +the manifest, a web page, notes kept in `${stateDir}` with progress, a +function registered at runtime, a secret in the credential store, and +cancellation. `node scripts/sdk-e2e.mjs` drives it through a real Lithe Core +session with the scenario in +[`sdk/e2e/plugin_sdk/backend.rs`](../e2e/plugin_sdk/backend.rs). + +## Getting the SDK + +Releases are published on +[this repository's Releases page](https://github.com/Lithe-IDEA/lithe-plugin-docs/releases) +and tagged `sdk-rust-v`. The SDK is not published to crates.io. +Either way below fetches it at build time, never at runtime, and pins its +exact contents: + +- **Git dependency.** Name the tag; `Cargo.lock` records the commit, and + `cargo build --locked` refuses anything else: + + ```toml + [dependencies] + lithe-plugin-sdk = { git = "https://github.com/Lithe-IDEA/lithe-plugin-docs", tag = "sdk-rust-v0.1.0" } + ``` + +- **Vendored archive.** A release ships `lithe-plugin-sdk-rust-.crate` + (a gzipped tarball, as `cargo package` makes it) and a `.sha256` file. + Check the archive against the SHA-256 you pinned, unpack it into your + repository, and use it as a `path` dependency. + +## API + +| API | Purpose | +| --- | --- | +| `Backend::new(name).version(version)` | Creates the backend; `name` and `version` are reported as `serverInfo`, and `name` prefixes log lines. | +| `backend.function(id, handler)` | Declares a function offered in `initialize`. | +| `backend.on_initialize(hook)` | Runs before the `initialize` answer with an `Initialize`: the host's `params()`, the negotiated `functions_version()`, `root_path()` (the plugin's `${stateDir}`), and `function(id, handler)` to add functions that depend on state found at startup. An error refuses `initialize`. | +| `backend.on_shutdown(hook)` | Runs on `shutdown`, after running calls were cancelled. | +| `backend.handle()` | A clonable `BackendHandle` for other threads. | +| `backend.listen()` | Serves stdio until `exit` or the end of input and returns the exit code: `0` after `shutdown` and `exit`, `1` otherwise. | +| `backend.serve(input, output)` | The same on other streams, for tests. | +| `handle.register_function(id, handler)`, `handle.unregister_function(id)` | Adds or withdraws a function after `initialize` and waits until the host accepted it. Withdrawing revokes and cancels running calls; a handler must not withdraw its own function. | +| `handle.function_ids()`, `handle.functions_version()` | What the backend serves and negotiated. | +| `handle.credentials()`, `context.credentials()` | The credentials service: `get(key)`, `set(key, secret)`, `delete(key)`. | +| `PluginError::new(code, message)` | A failure meant for your own UI. | + +A handler is `Fn(Value, &Context) -> Result`. Its +context offers: + +| Member | Meaning | +| --- | --- | +| `cancellation()` | A `Cancellation`, fired when Lithe cancels the call, its deadline passes, the function is withdrawn, or the session ends: `is_cancelled()`, `check()`, `wait_timeout(duration)` (use it instead of `thread::sleep`), and `on_cancel(callback)`, for example to cancel a running database query from the driver. | +| `check_cancelled()` | `Err(Cancelled)` once cancelled, for `?`. | +| `progress(message, percentage)` | The message is cut to 1024 bytes and the percentage to 100; reports after the call ended are dropped. | +| `credentials()` | Credentials bound to the call: a request still waiting for the host when the call is cancelled ends at once. | +| `backend()`, `function_id()`, `log(message)` | The handle, the function's ID, and a line on stderr. | + +## Credentials + +A key is 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`; a secret is at +most 16 KiB. Credentials are private to your plugin ID, survive disabling and +updating the plugin, and are deleted when it is uninstalled. They are never +written to `${stateDir}` or the package. + +| Failure (`SdkError::code()`) | Meaning | +| --- | --- | +| `sdk.protocol.notNegotiated` | The host offered only protocol 1, so it has no host services. Nothing was sent. | +| `plugin.host.permissionDenied` | The plugin lacks the `credentials.store` permission. | +| `plugin.host.invalidParams`, `sdk.credentials.invalidKey`, `sdk.credentials.invalidSecret` | A malformed key or secret. | +| `plugin.host.failed` | The host's secret store failed, for example a locked Keychain; the message says why and never contains the secret. | +| `sdk.call.cancelled` | The call was cancelled while waiting; `?` answers as cancelled. | +| `sdk.session.closed` | The session ended while waiting. | + +## Results and errors + +| Handler outcome | What Lithe sees | +| --- | --- | +| `Ok(value)` (including `Value::Null`) | `result.value` | +| `Err(PluginError)` | `plugin.function.failed` with your message and `pluginErrorCode`. The code is for your plugin's own UI; Lithe never branches on it. | +| Any other error, or a panic | `plugin.function.failed` with a generic message, so paths, secrets, and stack traces never reach the UI. The details go to stderr. A panic ends only its call. | +| `Err(Cancelled)`, or any outcome after cancellation | `requestCancelled` (or Lithe's timeout or revocation outcome) | + +`?` converts `PluginError`, `Cancelled`, `SdkError`, `std::io::Error`, and +`serde_json::Error` into `FunctionError`; `FunctionError::internal(details)` +wraps anything else. A result must serialize to at most 1 MiB of JSON. A +function ID is dot-separated lowerCamelCase segments (`snapshot.refresh`), at +most 128 bytes, and a session holds at most 256 functions. A `PluginError` +code matches `[A-Za-z][A-Za-z0-9._-]*`, at most 64 bytes; `PluginError::new` +panics on any other code. + +## Rules that keep Lithe in charge + +1. **stdout belongs to the protocol.** On Unix, `listen()` moves the + protocol to a duplicate of the stdout descriptor and points stdout at + stderr, so a stray `println!` in your code or a dependency lands in the + host's log. Elsewhere, never print to stdout. +2. **The package is read-only.** Keep caches, databases, and logs under + `${stateDir}`, which Lithe also passes as the root and working directory. + Writing to the package breaks code signing and update deltas. +3. **Secrets go to the credentials service.** Never write a password or + token to `${stateDir}`, to a log line, or to a `PluginError` message. +4. **Work ends with its call.** Check the cancellation in long loops, use + `wait_timeout` between retries, and register an `on_cancel` callback for + blocking driver calls. Lithe has already ended the call when it cancels; + the SDK answers `requestCancelled` whatever the handler returns. +5. **No calls back into Lithe's dispatch.** The SDK never sends + `lithe/function/invoke` or `lithe/build/*`; Lithe would refuse them as + `plugin.function.recursiveCall`. + +## Testing + +```sh +cargo test --locked --manifest-path sdk/rust/Cargo.toml +cargo clippy --locked --manifest-path sdk/rust/Cargo.toml --all-targets -- -D warnings +node scripts/sdk-e2e.mjs +``` + +The unit tests drive a session through in-process pipes with a scripted +host. The end-to-end check builds the sample, installs it as a package, and +runs it through a real Core session. diff --git a/sdk/rust/examples/sample-backend/Cargo.toml b/sdk/rust/examples/sample-backend/Cargo.toml new file mode 100644 index 0000000..b6e173f --- /dev/null +++ b/sdk/rust/examples/sample-backend/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "sample-backend" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +lithe-plugin-sdk = { path = "../../plugin-sdk" } +serde_json.workspace = true diff --git a/sdk/rust/examples/sample-backend/plugin.json b/sdk/rust/examples/sample-backend/plugin.json new file mode 100644 index 0000000..8f752e0 --- /dev/null +++ b/sdk/rust/examples/sample-backend/plugin.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 2, + "id": "org.example.sample-backend", + "displayName": "Sample Backend", + "version": "0.1.0", + "pluginAPIVersion": 3, + "requires": { "backend": 1, "serverProcess": 1, "ui": 2 }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": ["backend.launch", "credentials.store", "ui.contribute", "ui.web"], + "activationEvents": ["onView:org.example.sample-backend.notes"], + "languages": [], + "ui": { + "toolWindows": [ + { + "id": "org.example.sample-backend.notes", + "title": "Sample Notes", + "placement": "right", + "web": { "entry": "ui/index.html" } + } + ] + }, + "backend": { + "launch": { "kind": "packaged", "path": "bin/sample-backend" }, + "arguments": ["--state", "${stateDir}"] + } +} diff --git a/sdk/rust/examples/sample-backend/src/main.rs b/sdk/rust/examples/sample-backend/src/main.rs new file mode 100644 index 0000000..e8da003 --- /dev/null +++ b/sdk/rust/examples/sample-backend/src/main.rs @@ -0,0 +1,220 @@ +//! Sample plugin backend for the Rust SDK: the `backend` process of the +//! sample plugin in this directory. Lithe starts it when the plugin's web +//! page first calls a function, with `--state ${stateDir}`. It: +//! +//! - keeps notes in its state directory, with progress (`note.save`, +//! `note.read`), and registers `note.clear` once there is a note to clear; +//! - keeps a secret in the host's credential store, never in its state +//! (`secret.remember`, `secret.check`, `secret.forget`); +//! - waits until it is cancelled (`work.wait`), and fails unexpectedly +//! without leaking details to the page (`fail.unexpectedly`). + +use lithe_plugin_sdk::{ + sdk_codes, BackendHandle, Cancelled, Context, FunctionError, PluginError, SdkError, Value, +}; +use serde_json::json; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +fn main() { + let state = match state_directory() { + Ok(state) => Arc::new(state), + Err(message) => { + eprintln!("{message}"); + std::process::exit(2); + } + }; + let mut backend = + lithe_plugin_sdk::Backend::new("sample-backend").version(env!("CARGO_PKG_VERSION")); + + let notes = Arc::clone(&state); + backend + .function("note.save", move |params, context| { + let name = note_name(¶ms)?; + let text = params["text"].as_str().ok_or_else(|| { + PluginError::new("sample.invalidParams", "\"text\" must be a string.") + })?; + context.progress("Saving", Some(0)); + let directory = notes.join("notes"); + fs::create_dir_all(&directory)?; + // Written beside the note and renamed, so a reader never sees half a note. + let temporary = directory.join(format!(".{name}.tmp")); + fs::write(&temporary, text)?; + fs::rename(&temporary, directory.join(format!("{name}.txt")))?; + context.progress("Saved", Some(100)); + offer_clear(&context.backend(), ¬es); + Ok(json!({ "name": name, "bytes": text.len() })) + }) + .expect("valid function"); + + let notes = Arc::clone(&state); + backend + .function("note.read", move |params, _| { + let name = note_name(¶ms)?; + match fs::read_to_string(notes.join("notes").join(format!("{name}.txt"))) { + Ok(text) => Ok(json!({ "text": text })), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Err(PluginError::new( + "sample.noteMissing", + format!("There is no note named {name}."), + ) + .into()) + } + Err(error) => Err(error.into()), + } + }) + .expect("valid function"); + + backend + .function("secret.remember", |params, context| { + let (key, secret) = (credential_key(¶ms)?, secret(¶ms)?); + context + .credentials() + .set(key, secret) + .map_err(credentials_failure)?; + Ok(json!({})) + }) + .expect("valid function"); + + backend + .function("secret.check", |params, context| { + let (key, secret) = (credential_key(¶ms)?, secret(¶ms)?); + let stored = context + .credentials() + .get(key) + .map_err(credentials_failure)?; + // The page learns whether its input matches, never the secret. + Ok(json!({ "stored": stored.is_some(), "matches": stored.as_deref() == Some(secret) })) + }) + .expect("valid function"); + + backend + .function("secret.forget", |params, context| { + let key = credential_key(¶ms)?; + context + .credentials() + .delete(key) + .map_err(credentials_failure)?; + Ok(Value::Null) + }) + .expect("valid function"); + + let markers = Arc::clone(&state); + backend + .function("work.wait", move |_, context| { + fs::write(markers.join("waiting"), "")?; + // Blocks without spinning until the host cancels the call. + if context + .cancellation() + .wait_timeout(Duration::from_secs(600)) + { + fs::write(markers.join("cancelled"), "")?; + return Err(Cancelled.into()); + } + Ok(json!("finished")) + }) + .expect("valid function"); + + let hidden = Arc::clone(&state); + backend + .function("fail.unexpectedly", move |_, _| { + Err(FunctionError::internal(format!( + "could not open {}", + hidden.join("missing").display() + ))) + }) + .expect("valid function"); + + let notes = Arc::clone(&state); + backend.on_initialize(move |initialize| { + if has_notes(¬es) { + let notes = Arc::clone(¬es); + initialize.function("note.clear", move |_, context| clear(¬es, context))?; + } + Ok(()) + }); + + std::process::exit(backend.listen()); +} + +fn state_directory() -> Result { + let mut arguments = std::env::args().skip(1); + match (arguments.next().as_deref(), arguments.next()) { + (Some("--state"), Some(path)) if arguments.next().is_none() => Ok(PathBuf::from(path)), + _ => Err("usage: sample-backend --state ".into()), + } +} + +/// Registers `note.clear` the first time there is a note to clear. +fn offer_clear(backend: &BackendHandle, state: &Arc) { + if backend.function_ids().iter().any(|id| id == "note.clear") { + return; + } + let notes = Arc::clone(state); + if let Err(error) = + backend.register_function("note.clear", move |_, context| clear(¬es, context)) + { + backend.log(format_args!("note.clear stays unavailable: {error}")); + } +} + +fn clear(state: &Path, context: &Context) -> Result { + context.check_cancelled()?; + match fs::remove_dir_all(state.join("notes")) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error.into()), + _ => Ok(Value::Null), + } +} + +fn has_notes(state: &Path) -> bool { + fs::read_dir(state.join("notes")).is_ok_and(|mut entries| entries.next().is_some()) +} + +/// A note name is a file name the sample chooses the alphabet of, so a page +/// can never name a path outside the notes directory. +fn note_name(params: &Value) -> Result<&str, PluginError> { + params["name"] + .as_str() + .filter(|name| { + (1..=64).contains(&name.len()) + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + }) + .ok_or_else(|| { + PluginError::new( + "sample.invalidParams", + "\"name\" must be 1-64 lowercase letters, digits, or \"-\".", + ) + }) +} + +fn credential_key(params: &Value) -> Result<&str, PluginError> { + params["key"] + .as_str() + .filter(|key| lithe_plugin_sdk::is_credential_key(key)) + .ok_or_else(|| PluginError::new("sample.invalidParams", "\"key\" is not a credential key.")) +} + +fn secret(params: &Value) -> Result<&str, PluginError> { + params["secret"] + .as_str() + .ok_or_else(|| PluginError::new("sample.invalidParams", "\"secret\" must be a string.")) +} + +/// A host that offers no credential store, or a plugin installed without the +/// `credentials.store` permission, is a state the page can explain; other +/// failures stay internal. +fn credentials_failure(error: SdkError) -> FunctionError { + if error.code() == sdk_codes::NOT_NEGOTIATED || error.code().starts_with("plugin.host.") { + PluginError::new( + "sample.credentialsUnavailable", + "Lithe cannot store credentials for this plugin.", + ) + .into() + } else { + error.into() + } +} diff --git a/sdk/rust/examples/sample-backend/ui/index.html b/sdk/rust/examples/sample-backend/ui/index.html new file mode 100644 index 0000000..78447cf --- /dev/null +++ b/sdk/rust/examples/sample-backend/ui/index.html @@ -0,0 +1,23 @@ + + + + + Sample Notes + + + + +
+ + + + +
+
+ + + +
+

+ + diff --git a/sdk/rust/examples/sample-backend/ui/main.js b/sdk/rust/examples/sample-backend/ui/main.js new file mode 100644 index 0000000..82da4b2 --- /dev/null +++ b/sdk/rust/examples/sample-backend/ui/main.js @@ -0,0 +1,43 @@ +// The page reaches its backend only through the host bridge: functions.invoke +// without an instance calls this plugin's backend, which Lithe starts on the +// first call. Inline scripts are refused by the page's content security policy. +const status = document.getElementById('status'); +const note = document.getElementById('note'); +const secret = document.getElementById('secret'); +let calls = 0; + +async function call(fn, params) { + const id = `call-${Date.now()}-${calls++}`; + try { + const { value } = await lithe.request('functions.invoke', { function: fn, params, call: id }); + return value; + } catch (error) { + status.textContent = error.pluginErrorCode ? `${error.message} (${error.pluginErrorCode})` : error.message; + throw error; + } +} + +lithe.on('functions.progress', ({ message }) => { if (message) status.textContent = message; }); + +note.addEventListener('submit', async (event) => { + event.preventDefault(); + const { bytes } = await call('note.save', { name: note.name.value, text: note.text.value }); + status.textContent = `Saved ${bytes} bytes.`; +}); + +document.getElementById('read').addEventListener('click', async () => { + note.text.value = (await call('note.read', { name: note.name.value })).text; + status.textContent = 'Loaded.'; +}); + +secret.addEventListener('submit', async (event) => { + event.preventDefault(); + await call('secret.remember', { key: 'sample:password', secret: secret.secret.value }); + secret.secret.value = ''; + status.textContent = 'Stored in the system credential store.'; +}); + +document.getElementById('check').addEventListener('click', async () => { + const { stored, matches } = await call('secret.check', { key: 'sample:password', secret: secret.secret.value }); + status.textContent = !stored ? 'Nothing stored.' : matches ? 'It matches.' : 'It does not match.'; +}); diff --git a/sdk/rust/examples/sample-backend/ui/style.css b/sdk/rust/examples/sample-backend/ui/style.css new file mode 100644 index 0000000..d397315 --- /dev/null +++ b/sdk/rust/examples/sample-backend/ui/style.css @@ -0,0 +1,3 @@ +body { font: var(--lithe-font-body, 13px system-ui); color: var(--lithe-text, CanvasText); background: transparent; margin: 12px; } +form { display: grid; gap: 6px; margin-bottom: 16px; } +label { display: grid; gap: 2px; } diff --git a/sdk/rust/plugin-sdk/Cargo.toml b/sdk/rust/plugin-sdk/Cargo.toml new file mode 100644 index 0000000..aeb2eae --- /dev/null +++ b/sdk/rust/plugin-sdk/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lithe-plugin-sdk" +description = "Official SDK for Lithe plugin backends: plugin functions and host services over the LSP base protocol." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +serde_json.workspace = true diff --git a/sdk/rust/plugin-sdk/src/backend.rs b/sdk/rust/plugin-sdk/src/backend.rs new file mode 100644 index 0000000..542cd61 --- /dev/null +++ b/sdk/rust/plugin-sdk/src/backend.rs @@ -0,0 +1,1119 @@ +//! The plugin backend session: one process the host starts from the plugin's +//! manifest and talks to over stdio with the LSP base protocol. The SDK owns +//! everything Lithe manages uniformly -- the lifecycle, protocol negotiation, +//! cancellation, error codes, and the `lithe/*` namespace -- and leaves the +//! plugin free to decide what its handlers do. +//! +//! Contract: `contract/v1/README.md` ("Plugin backend", "Plugin functions", +//! and "Host services"). + +use crate::error::{ + cancelled_response, internal_response, response_error, sdk_codes, Cancelled, FunctionError, + SdkError, +}; +use crate::framing::{read_frame, write_frame, Frame}; +use crate::protocol::{ + bounded_text, error_codes, is_credential_key, is_function_id, methods, FUNCTIONS_VERSIONS, + HOST_SERVICES_VERSION, MAX_CREDENTIAL_SECRET_BYTES, MAX_FUNCTIONS, MAX_MESSAGE_BYTES, + MAX_RESULT_BYTES, +}; +use serde_json::{json, Map, Value}; +use std::any::Any; +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Display; +use std::io::{self, BufReader, Read, Write}; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Condvar, Mutex, MutexGuard, PoisonError}; +use std::thread; +use std::time::Duration; + +type Handler = Arc Result + Send + Sync>; +type InitializeHook = Box Result<(), FunctionError> + Send>; +type ShutdownHook = Box; +type Answer = Result; +type Outcome = Result; + +/// `Idle` → `Listening` → `Initializing` → `Ready` → `ShuttingDown` → `Exited`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + Idle, + Listening, + Initializing, + Ready, + ShuttingDown, + Exited, +} + +struct State { + phase: Phase, + functions: BTreeMap, + /// The negotiated plugin-function protocol version. + functions_version: Option, + /// Host requests being handled, by JSON-RPC ID. + incoming: HashMap, + /// Requests this backend sent to the host, by JSON-RPC ID. + outgoing: HashMap)>, +} + +struct Running { + function_id: String, + cancellation: Cancellation, +} + +/// Everything handler threads share with the session. Locks are taken in the +/// order `registrations`, `state`, `output`, never the other way round. +struct Shared { + name: String, + state: Mutex, + output: Mutex>>, + /// Serializes register/unregister so the host sees them in call order. + registrations: Mutex<()>, + next_request: AtomicU64, +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The JSON-RPC ID as a map key; numbers and strings never collide. +fn request_key(id: &Value) -> String { + id.to_string() +} + +fn rpc_error(code: i64, message: impl Into) -> Value { + json!({ "code": code, "message": message.into() }) +} + +/// A plugin backend. Declare functions and hooks, then call +/// [`listen`](Self::listen). +/// +/// ```no_run +/// use lithe_plugin_sdk::{Backend, PluginError}; +/// use serde_json::json; +/// +/// let mut backend = Backend::new("example-backend").version("1.0.0"); +/// backend +/// .function("snapshot.refresh", |params, context| { +/// context.progress("Refreshing", Some(10)); +/// let name = params["name"].as_str().ok_or_else(|| { +/// PluginError::new("example.invalidName", "\"name\" must be a string.") +/// })?; +/// Ok(json!({ "refreshed": name })) +/// }) +/// .expect("a valid function ID"); +/// std::process::exit(backend.listen()); +/// ``` +pub struct Backend { + shared: Arc, + version: Option, + initialize_hooks: Vec, + shutdown_hooks: Vec, +} + +impl Backend { + /// `name` is reported as `serverInfo.name` and prefixes log lines. + pub fn new(name: impl Into) -> Self { + Self { + shared: Arc::new(Shared { + name: name.into(), + state: Mutex::new(State { + phase: Phase::Idle, + functions: BTreeMap::new(), + functions_version: None, + incoming: HashMap::new(), + outgoing: HashMap::new(), + }), + output: Mutex::new(None), + registrations: Mutex::new(()), + next_request: AtomicU64::new(1), + }), + version: None, + initialize_hooks: Vec::new(), + shutdown_hooks: Vec::new(), + } + } + + /// Reported as `serverInfo.version`. + pub fn version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } + + /// Declares a function offered from the start, in the `initialize` + /// answer. To add one later, use [`BackendHandle::register_function`]. + /// + /// Each call runs on a thread of its own, so a handler may block; it + /// should stop early when its [`Context::cancellation`] fires. + pub fn function(&mut self, id: &str, handler: F) -> Result<&mut Self, SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + let mut state = lock(&self.shared.state); + check_new_function(id, &state.functions)?; + state.functions.insert(id.to_string(), Arc::new(handler)); + drop(state); + Ok(self) + } + + /// Runs before the `initialize` answer. A hook may read the host's + /// params and add functions; an error refuses `initialize`, mapped like + /// a handler's. + pub fn on_initialize(&mut self, hook: F) -> &mut Self + where + F: FnMut(&mut Initialize) -> Result<(), FunctionError> + Send + 'static, + { + self.initialize_hooks.push(Box::new(hook)); + self + } + + /// Runs on `shutdown`, after running calls were cancelled. + pub fn on_shutdown(&mut self, hook: F) -> &mut Self + where + F: FnMut() + Send + 'static, + { + self.shutdown_hooks.push(Box::new(hook)); + self + } + + /// A handle for other threads: runtime registration, host services, and + /// logging. + pub fn handle(&self) -> BackendHandle { + BackendHandle { + shared: Arc::clone(&self.shared), + } + } + + /// Serves the host on stdin and stdout until it sends `exit` or closes + /// stdin, and returns the process exit code. Stdout carries only + /// protocol frames: on Unix the SDK moves the protocol to a duplicate of + /// the stdout descriptor and points stdout at stderr, so a stray + /// `println!`, in the plugin or a dependency, lands in the host's log + /// instead of corrupting the stream. + pub fn listen(self) -> i32 { + let output = crate::stdio::protocol_output(); + self.serve(io::stdin().lock(), output) + } + + /// Serves the host on the given streams; [`listen`](Self::listen) uses + /// stdin and stdout. + pub fn serve(mut self, input: impl Read, output: impl Write + Send + 'static) -> i32 { + lock(&self.shared.state).phase = Phase::Listening; + *lock(&self.shared.output) = Some(Box::new(output)); + let mut reader = BufReader::new(input); + loop { + match read_frame(&mut reader) { + Ok(None) => { + let code = i32::from(self.shared.phase() != Phase::ShuttingDown); + return self.shared.finish(code); + } + Err(error) => { + self.shared.log(format_args!("Ending the session: {error}")); + return self.shared.finish(1); + } + Ok(Some(Frame::Malformed)) => self.shared.respond( + &Value::Null, + Err(rpc_error( + error_codes::PARSE_ERROR, + "The message is not valid JSON.", + )), + ), + Ok(Some(Frame::Message(message))) => { + if let Some(code) = self.dispatch(message) { + return self.shared.finish(code); + } + } + } + } + } + + /// Handles one message; returns the exit code once the host sent `exit`. + fn dispatch(&mut self, message: Value) -> Option { + let Value::Object(message) = message else { + // Valid JSON that is not a message object, such as `null` or a batch. + self.shared.respond( + &Value::Null, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "The message is not a JSON-RPC object.", + )), + ); + return None; + }; + let id = message.get("id").filter(|id| !id.is_null()).cloned(); + let params = message.get("params").cloned().unwrap_or(Value::Null); + match (message.get("method").and_then(Value::as_str), id) { + (None, Some(id)) => { + self.shared.settle_outgoing(&id, &message); + None + } + (None, None) => None, + (Some(method), Some(id)) => { + self.handle_request(id, method, params); + None + } + (Some(method), None) => self.handle_notification(method, ¶ms), + } + } + + fn handle_request(&mut self, id: Value, method: &str, params: Value) { + match method { + methods::INITIALIZE => self.initialize(id, params), + methods::SHUTDOWN => self.shutdown(id), + _ => match self.shared.phase() { + Phase::Ready if method == methods::FUNCTION_INVOKE => { + Shared::invoke(&self.shared, id, ¶ms) + } + Phase::Ready => self.shared.respond( + &id, + Err(rpc_error( + error_codes::METHOD_NOT_FOUND, + format!("Unsupported method: {method}"), + )), + ), + Phase::Idle | Phase::Listening | Phase::Initializing => self.shared.respond( + &id, + Err(rpc_error( + error_codes::SERVER_NOT_INITIALIZED, + "The backend is not initialized.", + )), + ), + Phase::ShuttingDown | Phase::Exited => self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "The backend is shutting down.", + )), + ), + }, + } + } + + fn handle_notification(&mut self, method: &str, params: &Value) -> Option { + match method { + methods::EXIT => Some(i32::from(self.shared.phase() != Phase::ShuttingDown)), + methods::CANCEL_REQUEST => { + let key = request_key(¶ms["id"]); + let running = lock(&self.shared.state) + .incoming + .get(&key) + .map(|running| running.cancellation.clone()); + if let Some(cancellation) = running { + cancellation.cancel(); + } + None + } + // `initialized` and anything else a plugin backend is not sent. + _ => None, + } + } + + fn initialize(&mut self, id: Value, params: Value) { + { + let mut state = lock(&self.shared.state); + if state.phase != Phase::Listening { + drop(state); + return self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "initialize was already received.", + )), + ); + } + state.phase = Phase::Initializing; + } + let offered = params + .pointer("/capabilities/experimental/litheFunctions/versions") + .and_then(Value::as_array); + let functions_version = FUNCTIONS_VERSIONS.into_iter().find(|version| { + offered + .is_some_and(|offered| offered.iter().any(|entry| entry.as_u64() == Some(*version))) + }); + let mut initialize = Initialize { + params, + functions_version, + existing: lock(&self.shared.state).functions.keys().cloned().collect(), + added: BTreeMap::new(), + }; + for hook in &mut self.initialize_hooks { + let outcome = catch_unwind(AssertUnwindSafe(|| hook(&mut initialize))); + let error = match outcome { + Ok(Ok(())) => continue, + Ok(Err(error)) => { + self.shared.log(format_args!("initialize failed: {error}")); + response_error(&error) + } + Err(panic) => { + self.shared + .log(format_args!("initialize failed: {}", panic_message(&panic))); + internal_response() + } + }; + lock(&self.shared.state).phase = Phase::Listening; + return self.shared.respond(&id, Err(error)); + } + + // The state stays locked until the answer is written, so a function + // registered from another thread cannot overtake it. + let mut state = lock(&self.shared.state); + state.functions.extend(initialize.added); + state.functions_version = functions_version; + let mut experimental = Map::new(); + match functions_version { + Some(version) => { + let functions: Vec<_> = state + .functions + .keys() + .map(|id| json!({ "id": id })) + .collect(); + experimental.insert( + "litheFunctions".into(), + json!({ "version": version, "functions": functions }), + ); + } + None if !state.functions.is_empty() => self.shared.log( + "The host did not offer litheFunctions version 1 or 2; functions stay unavailable.", + ), + None => {} + } + let mut server_info = json!({ "name": self.shared.name }); + if let Some(version) = &self.version { + server_info["version"] = json!(version); + } + state.phase = Phase::Ready; + self.shared.respond( + &id, + Ok(json!({ "capabilities": { "experimental": experimental }, "serverInfo": server_info })), + ); + } + + fn shutdown(&mut self, id: Value) { + let running = { + let mut state = lock(&self.shared.state); + if !matches!( + state.phase, + Phase::Listening | Phase::Initializing | Phase::Ready + ) { + drop(state); + return self.shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_REQUEST, + "shutdown was already received.", + )), + ); + } + state.phase = Phase::ShuttingDown; + state.running_cancellations() + }; + running.iter().for_each(Cancellation::cancel); + for hook in &mut self.shutdown_hooks { + if let Err(panic) = catch_unwind(AssertUnwindSafe(&mut *hook)) { + self.shared.log(format_args!( + "A shutdown hook failed: {}", + panic_message(&panic) + )); + } + } + self.shared.respond(&id, Ok(Value::Null)); + } +} + +impl State { + fn running_cancellations(&self) -> Vec { + self.incoming + .values() + .map(|running| running.cancellation.clone()) + .collect() + } + + fn require_functions(&self) -> Result<(), SdkError> { + if self.phase == Phase::Exited { + Err(SdkError::new( + sdk_codes::SESSION_CLOSED, + "The session ended.", + )) + } else if self.phase == Phase::Ready && self.functions_version.is_some() { + Ok(()) + } else { + Err(SdkError::new( + "sdk.functions.unavailable", + "The host has not negotiated plugin functions.", + )) + } + } +} + +impl Shared { + fn phase(&self) -> Phase { + lock(&self.state).phase + } + + fn functions_version(&self) -> Option { + lock(&self.state).functions_version + } + + fn log(&self, message: impl Display) { + let _ = writeln!(io::stderr(), "[{}] {message}", self.name); + } + + fn finish(&self, code: i32) -> i32 { + let running = { + let mut state = lock(&self.state); + state.phase = Phase::Exited; + // Dropping the senders ends every host request still waiting. + state.outgoing.clear(); + state.running_cancellations() + }; + running.iter().for_each(Cancellation::cancel); + *lock(&self.output) = None; + code + } + + fn write(&self, message: &Value) { + let mut output = lock(&self.output); + let Some(writer) = output.as_mut() else { + return; + }; + if let Err(error) = write_frame(writer.as_mut(), message) { + // Every later write would fail the same way. + *output = None; + drop(output); + self.log(format_args!("The host stream failed: {error}")); + } + } + + fn respond(&self, id: &Value, answer: Answer) { + self.write(&match answer { + Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), + Err(error) => json!({ "jsonrpc": "2.0", "id": id, "error": error }), + }); + } + + fn invoke(shared: &Arc, id: Value, params: &Value) { + let function_id = params["functionID"] + .as_str() + .unwrap_or_default() + .to_string(); + let operation_id = params["operationID"].clone(); + let call_params = match params.get("params") { + None | Some(Value::Null) => json!({}), + Some(params) => params.clone(), + }; + let key = request_key(&id); + let cancellation = Cancellation::new(); + let handler = { + let mut state = lock(&shared.state); + let handler = state + .functions_version + .and_then(|_| state.functions.get(&function_id).cloned()); + if handler.is_some() { + state.incoming.insert( + key.clone(), + Running { + function_id: function_id.clone(), + cancellation: cancellation.clone(), + }, + ); + } + handler + }; + let Some(handler) = handler else { + return shared.respond( + &id, + Err(rpc_error( + error_codes::INVALID_PARAMS, + format!("Function {function_id} is not registered."), + )), + ); + }; + let worker = Arc::clone(shared); + let thread_key = key.clone(); + let thread_id = id.clone(); + let spawned = thread::Builder::new() + .name(format!("lithe-function-{function_id}")) + .spawn(move || { + let context = Context { + shared: Arc::clone(&worker), + function_id, + operation_id, + cancellation, + reporting: AtomicBool::new(true), + }; + let outcome = catch_unwind(AssertUnwindSafe(|| handler(call_params, &context))); + context.reporting.store(false, Ordering::SeqCst); + let answer = worker.function_answer(&context, outcome); + lock(&worker.state).incoming.remove(&thread_key); + worker.respond(&thread_id, answer); + }); + if let Err(error) = spawned { + lock(&shared.state).incoming.remove(&key); + shared.log(format_args!("The call could not start: {error}")); + shared.respond(&id, Err(internal_response())); + } + } + + /// The answer to one call. A cancelled call answers as cancelled whatever + /// the handler returned, and only failures the host never sees in full + /// are logged with their details. + fn function_answer( + &self, + context: &Context, + outcome: Result, Box>, + ) -> Answer { + let cancelled = context.cancellation.is_cancelled(); + let function = &context.function_id; + match outcome { + _ if cancelled => Err(cancelled_response()), + Ok(Ok(value)) => { + let size = serde_json::to_vec(&value).map_or(usize::MAX, |bytes| bytes.len()); + if size > MAX_RESULT_BYTES { + self.log(format_args!( + "Function {function} returned {size} bytes; results are limited to {MAX_RESULT_BYTES}." + )); + Err(internal_response()) + } else { + Ok(value) + } + } + Ok(Err(error)) => { + match &error { + FunctionError::Plugin(error) => { + self.log(format_args!("Function {function} failed: {error}")) + } + FunctionError::Internal(details) => self.log(format_args!( + "Function {function} failed unexpectedly: {details}" + )), + FunctionError::Cancelled => {} + } + Err(response_error(&error)) + } + Err(panic) => { + self.log(format_args!( + "Function {function} panicked: {}", + panic_message(&panic) + )); + Err(internal_response()) + } + } + } + + /// Sends a request to the host and waits for its answer, the end of the + /// session, or the cancellation of the call that sent it. + fn request( + &self, + method: &'static str, + params: Value, + cancellation: Option<&Cancellation>, + ) -> Outcome { + let id = format!("sdk-{}", self.next_request.fetch_add(1, Ordering::Relaxed)); + let (sender, receiver) = mpsc::channel(); + { + let mut state = lock(&self.state); + match state.phase { + Phase::Ready => {} + Phase::Exited => { + return Err(SdkError::new( + sdk_codes::SESSION_CLOSED, + "The session ended.", + )) + } + _ => { + return Err(SdkError::new( + "sdk.session.notReady", + "The session is not ready for requests.", + )) + } + } + state.outgoing.insert(id.clone(), (method, sender.clone())); + } + if let Some(cancellation) = cancellation { + cancellation.on_cancel(move || { + let _ = sender.send(Err(SdkError::new( + sdk_codes::CALL_CANCELLED, + "The call was cancelled.", + ))); + }); + } else { + drop(sender); + } + self.write(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })); + let outcome = receiver.recv().unwrap_or_else(|_| { + Err(SdkError::new( + sdk_codes::SESSION_CLOSED, + "The session ended.", + )) + }); + lock(&self.state).outgoing.remove(&id); + outcome + } + + fn settle_outgoing(&self, id: &Value, message: &Map) { + let Some(id) = id.as_str() else { + return; + }; + let Some((method, sender)) = lock(&self.state).outgoing.remove(id) else { + return; + }; + let outcome = match message.get("error").filter(|error| !error.is_null()) { + Some(error) => Err(SdkError::from_response(method, error)), + None => Ok(message.get("result").cloned().unwrap_or(Value::Null)), + }; + let _ = sender.send(outcome); + } +} + +fn check_new_function(id: &str, functions: &BTreeMap) -> Result<(), SdkError> { + if !is_function_id(id) { + return Err(SdkError::new( + "sdk.function.invalidID", + format!("Function ID {id:?} must be dot-separated lowerCamelCase segments of at most 128 bytes."), + )); + } + if functions.contains_key(id) { + return Err(SdkError::new( + "sdk.function.duplicate", + format!("Function {id} is already declared."), + )); + } + if functions.len() >= MAX_FUNCTIONS { + return Err(SdkError::new( + "sdk.function.limit", + format!("A backend serves at most {MAX_FUNCTIONS} functions."), + )); + } + Ok(()) +} + +fn panic_message(panic: &Box) -> String { + panic + .downcast_ref::<&str>() + .map(|message| message.to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "a panic without a message".into()) +} + +/// What a hook sees of `initialize`, before the backend answers it. +pub struct Initialize { + params: Value, + functions_version: Option, + existing: Vec, + added: BTreeMap, +} + +impl Initialize { + /// The host's `initialize` params. + pub fn params(&self) -> &Value { + &self.params + } + + /// The plugin-function protocol version the backend will answer, or + /// `None` when the host offered none this SDK speaks. + pub fn functions_version(&self) -> Option { + self.functions_version + } + + /// The `rootUri` as a path. For a plugin backend this is the plugin's + /// `${stateDir}`, also the working directory. + pub fn root_path(&self) -> Option { + self.params["rootUri"].as_str().and_then(file_uri_path) + } + + /// Adds a function to the initial list, for example one that depends on + /// state found at startup. + pub fn function(&mut self, id: &str, handler: F) -> Result<(), SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + if self.existing.iter().any(|existing| existing == id) { + return Err(SdkError::new( + "sdk.function.duplicate", + format!("Function {id} is already declared."), + )); + } + if self.existing.len() + self.added.len() >= MAX_FUNCTIONS { + return Err(SdkError::new( + "sdk.function.limit", + format!("A backend serves at most {MAX_FUNCTIONS} functions."), + )); + } + check_new_function(id, &self.added)?; + self.added.insert(id.to_string(), Arc::new(handler)); + Ok(()) + } +} + +/// The path of a `file:` URI, percent-decoded. +fn file_uri_path(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let path = rest.strip_prefix("localhost").unwrap_or(rest); + if !path.starts_with('/') { + return None; + } + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).ok()?; + decoded.push(u8::from_str_radix(hex, 16).ok()?); + index += 3; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + let mut path = String::from_utf8(decoded).ok()?; + // `/C:/Users` on Windows. + if cfg!(windows) && path.as_bytes().get(2) == Some(&b':') { + path.remove(0); + } + while path.len() > 1 && path.ends_with('/') { + path.pop(); + } + Some(PathBuf::from(path)) +} + +/// One running function call. +pub struct Context { + shared: Arc, + function_id: String, + operation_id: Value, + cancellation: Cancellation, + reporting: AtomicBool, +} + +impl Context { + pub fn function_id(&self) -> &str { + &self.function_id + } + + /// Fires when the host cancels the call, withdraws its function, or + /// shuts the backend down. Core has already ended the call by then, so a + /// handler should stop its work and return promptly. + pub fn cancellation(&self) -> &Cancellation { + &self.cancellation + } + + /// `Err(Cancelled)` once the call is cancelled, for use with `?`. + pub fn check_cancelled(&self) -> Result<(), Cancelled> { + self.cancellation.check() + } + + /// Reports progress of this call; the host shows the latest report. + /// `message` is cut to 1024 bytes and `percentage` to 100. Reports after + /// the call ended are dropped. + pub fn progress(&self, message: &str, percentage: Option) { + if !self.reporting.load(Ordering::SeqCst) { + return; + } + let mut report = json!({ + "operationID": self.operation_id, + "message": bounded_text(message, MAX_MESSAGE_BYTES), + }); + if let Some(percentage) = percentage { + report["percentage"] = json!(percentage.min(100)); + } + self.shared.write( + &json!({ "jsonrpc": "2.0", "method": methods::FUNCTION_PROGRESS, "params": report }), + ); + } + + /// The plugin's credentials. A request still waiting for the host when + /// the call is cancelled fails with `sdk.call.cancelled`, which `?` + /// turns into a cancelled answer. + pub fn credentials(&self) -> Credentials { + Credentials { + shared: Arc::clone(&self.shared), + cancellation: Some(self.cancellation.clone()), + } + } + + pub fn backend(&self) -> BackendHandle { + BackendHandle { + shared: Arc::clone(&self.shared), + } + } + + /// Writes a diagnostic line to stderr, which the host keeps in its log. + pub fn log(&self, message: impl Display) { + self.shared.log(message); + } +} + +/// A clonable handle to a backend for any thread. +#[derive(Clone)] +pub struct BackendHandle { + shared: Arc, +} + +impl BackendHandle { + /// The negotiated plugin-function protocol version; `None` before + /// `initialize` or when the host offered none this SDK speaks. + pub fn functions_version(&self) -> Option { + self.shared.functions_version() + } + + /// IDs of the functions the backend currently serves, sorted. + pub fn function_ids(&self) -> Vec { + lock(&self.shared.state).functions.keys().cloned().collect() + } + + /// Adds a function after `initialize` and waits until the host accepted + /// it. The handler is installed first, because the host may invoke it as + /// soon as its registry changed. + pub fn register_function(&self, id: &str, handler: F) -> Result<(), SdkError> + where + F: Fn(Value, &Context) -> Result + Send + Sync + 'static, + { + let _order = lock(&self.shared.registrations); + { + let mut state = lock(&self.shared.state); + state.require_functions()?; + check_new_function(id, &state.functions)?; + state.functions.insert(id.to_string(), Arc::new(handler)); + } + let registered = self.shared.request( + methods::FUNCTION_REGISTER, + json!({ "functions": [{ "id": id }] }), + None, + ); + if registered.is_err() { + lock(&self.shared.state).functions.remove(id); + } + registered.map(drop) + } + + /// Withdraws a function and waits until the host accepted it. The host + /// ends the function's running calls as revoked, and the SDK cancels + /// them. A handler must not withdraw its own function: that revokes the + /// call it runs in. + pub fn unregister_function(&self, id: &str) -> Result<(), SdkError> { + let _order = lock(&self.shared.registrations); + { + let state = lock(&self.shared.state); + state.require_functions()?; + if !state.functions.contains_key(id) { + return Err(SdkError::new( + "sdk.function.unknown", + format!("Function {id} is not registered."), + )); + } + } + self.shared + .request(methods::FUNCTION_UNREGISTER, json!({ "ids": [id] }), None)?; + let running: Vec<_> = { + let mut state = lock(&self.shared.state); + state.functions.remove(id); + state + .incoming + .values() + .filter(|running| running.function_id == id) + .map(|running| running.cancellation.clone()) + .collect() + }; + running.iter().for_each(Cancellation::cancel); + Ok(()) + } + + /// The plugin's credentials, for work outside a function call. + pub fn credentials(&self) -> Credentials { + Credentials { + shared: Arc::clone(&self.shared), + cancellation: None, + } + } + + /// Writes a diagnostic line to stderr, which the host keeps in its log. + pub fn log(&self, message: impl Display) { + self.shared.log(message); + } +} + +/// The host's `credentials` service (plugin-function protocol 2): secrets +/// private to this plugin, kept in the platform's secret store, never in +/// `${stateDir}`. It needs the `credentials.store` permission; without it +/// every request fails with `plugin.host.permissionDenied`. +pub struct Credentials { + shared: Arc, + cancellation: Option, +} + +impl Credentials { + /// The secret stored under `key`, or `None`. + pub fn get(&self, key: &str) -> Result, SdkError> { + let result = self.request(methods::CREDENTIALS_GET, key, json!({ "key": key }))?; + match &result["secret"] { + Value::String(secret) => Ok(Some(secret.clone())), + Value::Null => Ok(None), + _ => Err(SdkError::new( + "sdk.host.invalidResult", + "The host answered without a secret.", + )), + } + } + + /// Stores `secret` (at most 16 KiB) under `key`, replacing any value. + pub fn set(&self, key: &str, secret: &str) -> Result<(), SdkError> { + if secret.len() > MAX_CREDENTIAL_SECRET_BYTES { + return Err(SdkError::new( + "sdk.credentials.invalidSecret", + format!("A secret is at most {MAX_CREDENTIAL_SECRET_BYTES} bytes."), + )); + } + self.request( + methods::CREDENTIALS_SET, + key, + json!({ "key": key, "secret": secret }), + ) + .map(drop) + } + + /// Removes the secret under `key`; succeeds when nothing was stored. + pub fn delete(&self, key: &str) -> Result<(), SdkError> { + self.request(methods::CREDENTIALS_DELETE, key, json!({ "key": key })) + .map(drop) + } + + fn request(&self, method: &'static str, key: &str, params: Value) -> Outcome { + if !is_credential_key(key) { + return Err(SdkError::new( + "sdk.credentials.invalidKey", + "A credential key is 1-128 ASCII letters, digits, \".\", \"_\", \":\", or \"-\".", + )); + } + if self + .shared + .functions_version() + .is_none_or(|version| version < HOST_SERVICES_VERSION) + { + return Err(SdkError::new( + sdk_codes::NOT_NEGOTIATED, + "The host did not negotiate plugin-function protocol 2, which offers credentials.", + )); + } + self.shared + .request(method, params, self.cancellation.as_ref()) + } +} + +/// The cancellation of one call. Clones observe the same call. +#[derive(Clone)] +pub struct Cancellation(Arc); + +struct CancellationInner { + state: Mutex, + changed: Condvar, +} + +struct CancellationState { + cancelled: bool, + callbacks: Vec>, +} + +impl Cancellation { + fn new() -> Self { + Self(Arc::new(CancellationInner { + state: Mutex::new(CancellationState { + cancelled: false, + callbacks: Vec::new(), + }), + changed: Condvar::new(), + })) + } + + pub fn is_cancelled(&self) -> bool { + lock(&self.0.state).cancelled + } + + /// `Err(Cancelled)` once cancelled, for use with `?`. + pub fn check(&self) -> Result<(), Cancelled> { + if self.is_cancelled() { + Err(Cancelled) + } else { + Ok(()) + } + } + + /// Blocks for at most `timeout`, and returns whether the call was + /// cancelled. Use it instead of `thread::sleep` between retries. + pub fn wait_timeout(&self, timeout: Duration) -> bool { + let state = lock(&self.0.state); + let (state, _) = self + .0 + .changed + .wait_timeout_while(state, timeout, |state| !state.cancelled) + .unwrap_or_else(PoisonError::into_inner); + state.cancelled + } + + /// Runs `callback` once, when the call is cancelled, or right away when + /// it already is: for example to cancel a query a driver is running. + /// The callback runs on the thread that cancels, so it must not block. + pub fn on_cancel(&self, callback: impl FnOnce() + Send + 'static) { + let mut state = lock(&self.0.state); + if state.cancelled { + drop(state); + callback(); + } else { + state.callbacks.push(Box::new(callback)); + } + } + + pub(crate) fn cancel(&self) { + let callbacks = { + let mut state = lock(&self.0.state); + if state.cancelled { + return; + } + state.cancelled = true; + std::mem::take(&mut state.callbacks) + }; + self.0.changed.notify_all(); + for callback in callbacks { + callback(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_uris_become_paths() { + assert_eq!( + file_uri_path("file:///var/state/"), + Some(PathBuf::from("/var/state")) + ); + assert_eq!( + file_uri_path("file://localhost/a%20b/%C3%A9"), + Some(PathBuf::from("/a b/é")) + ); + assert_eq!(file_uri_path("file:///"), Some(PathBuf::from("/"))); + assert_eq!(file_uri_path("https://example.com/"), None); + assert_eq!(file_uri_path("file:///bad%zz"), None); + } + + #[test] + fn cancellation_wakes_waiters_and_runs_callbacks_once() { + let cancellation = Cancellation::new(); + assert!(!cancellation.wait_timeout(Duration::ZERO)); + let (sender, receiver) = mpsc::channel(); + let early = sender.clone(); + cancellation.on_cancel(move || early.send("registered").unwrap()); + let waiter = { + let cancellation = cancellation.clone(); + thread::spawn(move || cancellation.wait_timeout(Duration::from_secs(10))) + }; + cancellation.cancel(); + cancellation.cancel(); + assert!(waiter.join().unwrap()); + cancellation.on_cancel(move || sender.send("late").unwrap()); + let received: Vec<_> = receiver.try_iter().collect(); + assert_eq!(received, ["registered", "late"]); + assert_eq!(cancellation.check(), Err(Cancelled)); + } +} diff --git a/sdk/rust/plugin-sdk/src/error.rs b/sdk/rust/plugin-sdk/src/error.rs new file mode 100644 index 0000000..d0a5182 --- /dev/null +++ b/sdk/rust/plugin-sdk/src/error.rs @@ -0,0 +1,240 @@ +//! The three ways a call can fail, and the JSON-RPC error each becomes. + +use crate::protocol::{bounded_text, error_codes, is_plugin_error_code, MAX_MESSAGE_BYTES}; +use serde_json::{json, Value}; +use std::fmt; + +/// A failure a handler reports on purpose. Core ends the call with the stable +/// `plugin.function.failed` code and forwards `code` as `pluginErrorCode`, so +/// the plugin's own UI can tell its failures apart. Lithe never branches on +/// it. The message is shown to the user as written, so it must not contain +/// secrets, stack traces, or machine paths. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginError { + code: String, + message: String, +} + +impl PluginError { + /// `code` is an identifier of at most 64 bytes, such as + /// `db.connectionFailed`; Core keeps the first 1024 bytes of `message`. + /// + /// # Panics + /// + /// When `code` does not start with a letter, uses characters other than + /// letters, digits, `.`, `_`, and `-`, or is longer than 64 bytes. In a + /// handler, that panic ends the call as an internal error. + pub fn new(code: impl Into, message: impl Into) -> Self { + let code = code.into(); + assert!( + is_plugin_error_code(&code), + "Plugin error code {code:?} must start with a letter, use letters, digits, \".\", \"_\" or \"-\", and be at most 64 bytes." + ); + Self { + code, + message: message.into(), + } + } + + pub fn code(&self) -> &str { + &self.code + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for PluginError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for PluginError {} + +/// A handler that noticed its call was cancelled; see +/// [`Cancellation::check`](crate::Cancellation::check). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Cancelled; + +impl fmt::Display for Cancelled { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("The call was cancelled.") + } +} + +impl std::error::Error for Cancelled {} + +/// An SDK usage error, or a host request that failed. `code` is the host's +/// `error.data.code` (such as `plugin.host.permissionDenied`) or one of the +/// SDK's own `sdk.*` codes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkError { + code: String, + message: String, + rpc_code: Option, +} + +/// SDK error codes a backend may want to tell apart. +pub mod sdk_codes { + /// The call that made the host request was cancelled first. + pub const CALL_CANCELLED: &str = "sdk.call.cancelled"; + /// The session ended before the host answered. + pub const SESSION_CLOSED: &str = "sdk.session.closed"; + /// The session did not negotiate the protocol version that offers this. + pub const NOT_NEGOTIATED: &str = "sdk.protocol.notNegotiated"; +} + +impl SdkError { + pub(crate) fn new(code: impl Into, message: impl Into) -> Self { + Self { + code: code.into(), + message: message.into(), + rpc_code: None, + } + } + + pub(crate) fn from_response(method: &str, error: &Value) -> Self { + Self { + code: error["data"]["code"] + .as_str() + .unwrap_or("sdk.host.error") + .to_string(), + message: error["message"] + .as_str() + .map_or_else(|| format!("{method} failed."), str::to_string), + rpc_code: error["code"].as_i64(), + } + } + + pub fn code(&self) -> &str { + &self.code + } + + pub fn message(&self) -> &str { + &self.message + } + + /// The JSON-RPC error code of a host response, such as `-32601`. + pub fn rpc_code(&self) -> Option { + self.rpc_code + } +} + +impl fmt::Display for SdkError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for SdkError {} + +/// How a function handler fails. `?` converts every error type a handler +/// typically meets: a [`PluginError`] keeps its code, [`Cancelled`] (and a +/// host request cut short by cancellation) answers as cancelled, and anything +/// else becomes a generic internal error whose details only reach stderr. +#[derive(Debug)] +pub enum FunctionError { + Plugin(PluginError), + Cancelled, + /// Logged to stderr; the host sees only that the backend failed. + Internal(String), +} + +impl FunctionError { + pub fn internal(details: impl fmt::Display) -> Self { + Self::Internal(details.to_string()) + } +} + +impl fmt::Display for FunctionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Plugin(error) => error.fmt(formatter), + Self::Cancelled => Cancelled.fmt(formatter), + Self::Internal(details) => formatter.write_str(details), + } + } +} + +impl From for FunctionError { + fn from(error: PluginError) -> Self { + Self::Plugin(error) + } +} + +impl From for FunctionError { + fn from(_: Cancelled) -> Self { + Self::Cancelled + } +} + +impl From for FunctionError { + fn from(error: SdkError) -> Self { + if error.code == sdk_codes::CALL_CANCELLED { + Self::Cancelled + } else { + Self::Internal(error.to_string()) + } + } +} + +impl From for FunctionError { + fn from(error: std::io::Error) -> Self { + Self::Internal(error.to_string()) + } +} + +impl From for FunctionError { + fn from(error: serde_json::Error) -> Self { + Self::Internal(error.to_string()) + } +} + +pub(crate) fn cancelled_response() -> Value { + json!({ "code": error_codes::REQUEST_CANCELLED, "message": "The request was cancelled." }) +} + +/// The JSON-RPC error object for a handler failure. Only a [`PluginError`] +/// carries a plugin code; anything else is a generic internal error, so +/// stack traces and machine paths never reach the host's UI. +pub(crate) fn response_error(error: &FunctionError) -> Value { + match error { + FunctionError::Cancelled => cancelled_response(), + FunctionError::Plugin(error) => json!({ + "code": error_codes::REQUEST_FAILED, + "message": bounded_text(&error.message, MAX_MESSAGE_BYTES), + "data": { "code": error.code }, + }), + FunctionError::Internal(_) => internal_response(), + } +} + +pub(crate) fn internal_response() -> Value { + json!({ "code": error_codes::INTERNAL_ERROR, "message": "The plugin backend failed unexpectedly." }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_plugin_errors_reach_the_host_with_their_message_and_code() { + let plugin = response_error(&PluginError::new("db.locked", "é".repeat(600)).into()); + assert_eq!(plugin["code"], -32000); + assert_eq!(plugin["data"]["code"], "db.locked"); + assert_eq!(plugin["message"].as_str().unwrap().len(), 1024); + let internal = response_error(&FunctionError::internal("/home/me/secret: No such file")); + assert_eq!(internal, internal_response()); + assert_eq!(response_error(&Cancelled.into())["code"], -32800); + let cut_short: FunctionError = SdkError::new(sdk_codes::CALL_CANCELLED, "x").into(); + assert!(matches!(cut_short, FunctionError::Cancelled)); + } + + #[test] + #[should_panic(expected = "must start with a letter")] + fn an_invalid_plugin_error_code_is_a_programming_error() { + let _ = PluginError::new("1bad", "message"); + } +} diff --git a/sdk/rust/plugin-sdk/src/framing.rs b/sdk/rust/plugin-sdk/src/framing.rs new file mode 100644 index 0000000..9eb203b --- /dev/null +++ b/sdk/rust/plugin-sdk/src/framing.rs @@ -0,0 +1,170 @@ +//! LSP base-protocol framing: `Content-Length` headers followed by a UTF-8 +//! JSON body. Lithe talks to every plugin backend this way, so the SDK owns +//! the framing and plugin code never writes to the protocol stream itself. + +use serde_json::Value; +use std::fmt; +use std::io::{self, BufRead, Read, Write}; + +/// Largest header block accepted from the host. +const MAX_HEADER_BYTES: usize = 8192; +/// Largest body accepted from the host. Larger frames end the session. +pub(crate) const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; + +/// One frame read from the host. +#[derive(Debug, PartialEq)] +pub(crate) enum Frame { + Message(Value), + /// A body that is not JSON; the framing position is intact. + Malformed, +} + +/// A framing error the session cannot recover from. +#[derive(Debug)] +pub(crate) struct FramingError(pub String); + +impl fmt::Display for FramingError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl From for FramingError { + fn from(error: io::Error) -> Self { + Self(format!("The input failed: {error}")) + } +} + +/// Reads the next frame, or `None` when the input ended between frames. +pub(crate) fn read_frame(reader: &mut impl BufRead) -> Result, FramingError> { + let mut header = Vec::new(); + let mut length = None; + loop { + let mut line = Vec::new(); + let remaining = (MAX_HEADER_BYTES + 1).saturating_sub(header.len()) as u64; + reader + .by_ref() + .take(remaining) + .read_until(b'\n', &mut line)?; + if line.is_empty() { + if header.is_empty() { + return Ok(None); + } + return Err(FramingError( + "The input ended inside a message header.".into(), + )); + } + header.extend_from_slice(&line); + // The same bound whether the header arrived whole or in pieces. + if header.len() > MAX_HEADER_BYTES { + return Err(FramingError("The message header is too long.".into())); + } + if !line.ends_with(b"\n") { + return Err(FramingError( + "The input ended inside a message header.".into(), + )); + } + let text = String::from_utf8_lossy(&line); + let text = text.trim_end_matches(['\r', '\n']); + if text.is_empty() { + break; + } + if length.is_none() { + length = content_length(text)?; + } + } + let length = length + .ok_or_else(|| FramingError("A message has no valid Content-Length header.".into()))?; + let mut body = vec![0; length]; + reader.read_exact(&mut body)?; + Ok(Some(match serde_json::from_slice(&body) { + Ok(message) => Frame::Message(message), + Err(_) => Frame::Malformed, + })) +} + +/// The length of a `Content-Length` line, `None` for any other header. +fn content_length(line: &str) -> Result, FramingError> { + let Some((name, value)) = line.split_once(':') else { + return Ok(None); + }; + if !name.trim().eq_ignore_ascii_case("content-length") { + return Ok(None); + } + let value = value.trim(); + let invalid = || FramingError("A message has no valid Content-Length header.".into()); + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid()); + } + let length: usize = value.parse().map_err(|_| invalid())?; + if length > MAX_FRAME_BYTES { + return Err(FramingError(format!( + "A message of {length} bytes exceeds the {MAX_FRAME_BYTES}-byte limit." + ))); + } + Ok(Some(length)) +} + +/// Writes one JSON-RPC message as a complete frame and flushes it. +pub(crate) fn write_frame(writer: &mut dyn Write, message: &Value) -> io::Result<()> { + let body = serde_json::to_vec(message)?; + let mut frame = format!("Content-Length: {}\r\n\r\n", body.len()).into_bytes(); + frame.extend_from_slice(&body); + writer.write_all(&frame)?; + writer.flush() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::io::BufReader; + + fn frames(input: &[u8]) -> Vec, String>> { + let mut reader = BufReader::with_capacity(3, input); + let mut frames = Vec::new(); + loop { + let frame = read_frame(&mut reader).map_err(|error| error.0); + let done = !matches!(frame, Ok(Some(_))); + frames.push(frame); + if done { + return frames; + } + } + } + + #[test] + fn decodes_consecutive_frames_and_reports_malformed_bodies_in_place() { + let mut input = Vec::new(); + write_frame(&mut input, &json!({"id": 1})).unwrap(); + input.extend_from_slice(b"content-length: 3\r\nContent-Type: x\r\n\r\nnot"); + write_frame(&mut input, &json!({"id": "é"})).unwrap(); + assert_eq!( + frames(&input), + [ + Ok(Some(Frame::Message(json!({"id": 1})))), + Ok(Some(Frame::Malformed)), + Ok(Some(Frame::Message(json!({"id": "é"})))), + Ok(None), + ] + ); + } + + #[test] + fn refuses_frames_it_cannot_delimit() { + let oversized = format!("Content-Length: {}\r\n\r\n", MAX_FRAME_BYTES + 1); + let long_header = format!("X: {}\r\n\r\n", "a".repeat(MAX_HEADER_BYTES)); + for (input, expected) in [ + ("Content-Type: x\r\n\r\n{}", "no valid Content-Length"), + ("Content-Length: -1\r\n\r\n", "no valid Content-Length"), + (oversized.as_str(), "exceeds"), + (long_header.as_str(), "too long"), + ("Content-Length: 2\r\n", "inside a message header"), + ("Content-Length: 4\r\n\r\n{}", "input failed"), + ] { + let last = frames(input.as_bytes()).pop().unwrap(); + let error = last.expect_err(input); + assert!(error.contains(expected), "{input:?}: {error}"); + } + } +} diff --git a/sdk/rust/plugin-sdk/src/lib.rs b/sdk/rust/plugin-sdk/src/lib.rs new file mode 100644 index 0000000..3c38ce0 --- /dev/null +++ b/sdk/rust/plugin-sdk/src/lib.rs @@ -0,0 +1,32 @@ +//! Official SDK for Lithe plugin backends written in Rust. +//! +//! A plugin backend is the one long-lived process a plugin declares under +//! `backend` in `plugin.json`; Lithe starts it when one of the plugin's own +//! web pages first calls a function. It speaks the LSP base protocol on +//! stdio and implements, from +//! [plugin contract v1](https://github.com/Lithe-IDEA/lithe-plugin-docs/blob/main/contract/v1/README.md): +//! +//! - **`litheFunctions` 1 and 2**: plugin functions with progress, +//! cancellation, and registration at runtime; +//! - **host services** of version 2: the plugin's own +//! [`Credentials`] in the platform's secret store. +//! +//! The SDK owns the wire format, negotiation, cancellation, and error +//! mapping; plugin code supplies handlers. Each call runs on a thread of its +//! own, so handlers may block. A handler returns `Ok(value)` with a JSON +//! result, a [`PluginError`] the plugin's UI can tell apart by its code, or +//! any other error, which reaches the host only as a generic failure while +//! its details go to stderr. A panic ends only its call. +//! +//! See [`Backend`] for a minimal backend. + +mod backend; +mod error; +mod framing; +mod protocol; +mod stdio; + +pub use backend::{Backend, BackendHandle, Cancellation, Context, Credentials, Initialize}; +pub use error::{sdk_codes, Cancelled, FunctionError, PluginError, SdkError}; +pub use protocol::{is_credential_key, is_function_id, is_plugin_error_code}; +pub use serde_json::Value; diff --git a/sdk/rust/plugin-sdk/src/protocol.rs b/sdk/rust/plugin-sdk/src/protocol.rs new file mode 100644 index 0000000..bb21c60 --- /dev/null +++ b/sdk/rust/plugin-sdk/src/protocol.rs @@ -0,0 +1,131 @@ +//! Wire constants and validation shared with Lithe's Rust Core +//! (`rust/lithe-core/src/lsp/interface/plugin_function.rs` in the Lithe +//! repository). The SDK checks the same rules locally so a plugin author sees +//! a mistake when declaring a function instead of as a refused session. + +/// Plugin-function protocol versions this SDK answers, newest first. +/// Version 2 keeps every rule of version 1 and adds host services. +pub const FUNCTIONS_VERSIONS: [u64; 2] = [2, 1]; +/// First plugin-function protocol version with host services. +pub const HOST_SERVICES_VERSION: u64 = 2; + +pub(crate) mod methods { + pub const INITIALIZE: &str = "initialize"; + pub const SHUTDOWN: &str = "shutdown"; + pub const EXIT: &str = "exit"; + pub const CANCEL_REQUEST: &str = "$/cancelRequest"; + pub const FUNCTION_INVOKE: &str = "lithe/function/invoke"; + pub const FUNCTION_REGISTER: &str = "lithe/function/register"; + pub const FUNCTION_UNREGISTER: &str = "lithe/function/unregister"; + pub const FUNCTION_PROGRESS: &str = "lithe/function/progress"; + pub const CREDENTIALS_GET: &str = "lithe/host/credentials/get"; + pub const CREDENTIALS_SET: &str = "lithe/host/credentials/set"; + pub const CREDENTIALS_DELETE: &str = "lithe/host/credentials/delete"; +} + +/// JSON-RPC and LSP error codes the SDK answers with. +pub(crate) mod error_codes { + pub const PARSE_ERROR: i64 = -32700; + pub const INVALID_REQUEST: i64 = -32600; + pub const METHOD_NOT_FOUND: i64 = -32601; + pub const INVALID_PARAMS: i64 = -32602; + pub const INTERNAL_ERROR: i64 = -32603; + pub const SERVER_NOT_INITIALIZED: i64 = -32002; + pub const REQUEST_FAILED: i64 = -32000; + pub const REQUEST_CANCELLED: i64 = -32800; +} + +pub(crate) const MAX_FUNCTIONS: usize = 256; +const MAX_FUNCTION_ID_BYTES: usize = 128; +const MAX_PLUGIN_ERROR_CODE_BYTES: usize = 64; +pub(crate) const MAX_MESSAGE_BYTES: usize = 1024; +pub(crate) const MAX_RESULT_BYTES: usize = 1 << 20; +const MAX_CREDENTIAL_KEY_BYTES: usize = 128; +pub(crate) const MAX_CREDENTIAL_SECRET_BYTES: usize = 16 * 1024; + +/// Dot-separated lowerCamelCase segments, for example `snapshot.refresh`. +pub fn is_function_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_FUNCTION_ID_BYTES + && value.split('.').all(|segment| { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && characters.all(|character| character.is_ascii_alphanumeric()) + }) +} + +/// The plugin error code alphabet Core forwards as `pluginErrorCode`. +pub fn is_plugin_error_code(value: &str) -> bool { + let mut characters = value.chars(); + value.len() <= MAX_PLUGIN_ERROR_CODE_BYTES + && characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + && characters + .all(|character| character.is_ascii_alphanumeric() || "._-".contains(character)) +} + +/// A credential key: 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`. +pub fn is_credential_key(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_CREDENTIAL_KEY_BYTES + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "._:-".contains(character)) +} + +/// Cuts `value` to `limit` UTF-8 bytes without splitting a character. +pub(crate) fn bounded_text(value: &str, limit: usize) -> String { + if value.len() <= limit { + return value.to_string(); + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn function_ids_are_dot_separated_lower_camel_case() { + assert!(is_function_id("snapshot.refresh")); + assert!(is_function_id("query.runAll2")); + for invalid in [ + "", + "Snapshot", + "snapshot.", + ".a", + "a..b", + "a-b", + "a.B", + "1a", + ] { + assert!(!is_function_id(invalid), "{invalid}"); + } + assert!(!is_function_id(&"a".repeat(129))); + } + + #[test] + fn plugin_error_codes_and_credential_keys_use_their_alphabets() { + assert!(is_plugin_error_code("db.connection-failed_2")); + assert!(!is_plugin_error_code("2db")); + assert!(!is_plugin_error_code(&"a".repeat(65))); + assert!(is_credential_key("connection:prod.password")); + assert!(!is_credential_key("")); + assert!(!is_credential_key("has space")); + assert!(!is_credential_key(&"k".repeat(129))); + } + + #[test] + fn bounded_text_never_splits_a_character() { + assert_eq!(bounded_text("abc", 8), "abc"); + assert_eq!(bounded_text("aé", 2), "a"); + assert_eq!(bounded_text("日本語", 7), "日本"); + } +} diff --git a/sdk/rust/plugin-sdk/src/stdio.rs b/sdk/rust/plugin-sdk/src/stdio.rs new file mode 100644 index 0000000..21f95f3 --- /dev/null +++ b/sdk/rust/plugin-sdk/src/stdio.rs @@ -0,0 +1,34 @@ +//! Keeps stdout for protocol frames only. + +use std::io::{self, Write}; + +/// The stream the protocol is written to. On Unix this is a duplicate of +/// the original stdout descriptor, and descriptor 1 is pointed at stderr, so +/// output printed by plugin code or its dependencies cannot corrupt the +/// protocol. Elsewhere it is stdout itself, and plugin code must not print. +pub(crate) fn protocol_output() -> Box { + #[cfg(unix)] + { + use std::os::fd::FromRawFd; + extern "C" { + fn dup(fd: i32) -> i32; + fn dup2(source: i32, target: i32) -> i32; + } + // SAFETY: `dup` and `dup2` only operate on the process's descriptor + // table. The duplicate is a fresh descriptor that nothing else owns, + // so the `File` becomes its single owner. + unsafe { + let protocol = dup(1); + if protocol >= 0 { + if dup2(2, 1) < 0 { + let _ = writeln!( + io::stderr(), + "Could not redirect stdout; printing to stdout corrupts the protocol." + ); + } + return Box::new(std::fs::File::from_raw_fd(protocol)); + } + } + } + Box::new(io::stdout()) +} diff --git a/sdk/rust/plugin-sdk/tests/session.rs b/sdk/rust/plugin-sdk/tests/session.rs new file mode 100644 index 0000000..9342446 --- /dev/null +++ b/sdk/rust/plugin-sdk/tests/session.rs @@ -0,0 +1,517 @@ +//! The backend session driven by a scripted host over in-process pipes. +//! Every wait is bounded, so a broken session fails here with the frames it +//! sent instead of hanging. + +use lithe_plugin_sdk::{Backend, FunctionError, PluginError, SdkError}; +use serde_json::{json, Value}; +use std::io::{self, BufRead, BufReader, Write}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +const WAIT: Duration = Duration::from_secs(10); + +/// The host side of one session. +struct Host { + input: Option, + frames: mpsc::Receiver, + exit: mpsc::Receiver, +} + +impl Host { + fn start(backend: Backend) -> Self { + let (backend_input, input) = io::pipe().expect("input pipe"); + let (output, backend_output) = io::pipe().expect("output pipe"); + let (exit_sender, exit) = mpsc::channel(); + thread::spawn(move || { + let _ = exit_sender.send(backend.serve(backend_input, backend_output)); + }); + let (frame_sender, frames) = mpsc::channel(); + thread::spawn(move || { + let mut reader = BufReader::new(output); + while let Some(frame) = read_frame(&mut reader) { + if frame_sender.send(frame).is_err() { + return; + } + } + }); + Self { + input: Some(input), + frames, + exit, + } + } + + fn send_raw(&mut self, bytes: &[u8]) { + let input = self.input.as_mut().expect("open input"); + input.write_all(bytes).expect("write to the backend"); + } + + fn send(&mut self, message: Value) { + let body = message.to_string(); + self.send_raw(format!("Content-Length: {}\r\n\r\n{body}", body.len()).as_bytes()); + } + + fn request(&mut self, id: impl Into, method: &str, params: Value) { + self.send(json!({ "jsonrpc": "2.0", "id": id.into(), "method": method, "params": params })); + } + + fn notify(&mut self, method: &str, params: Value) { + self.send(json!({ "jsonrpc": "2.0", "method": method, "params": params })); + } + + fn next(&self) -> Value { + self.frames + .recv_timeout(WAIT) + .expect("a frame from the backend in time") + } + + /// The answer to request `id`, after any frames before it. + fn answer(&self, id: impl Into) -> (Value, Vec) { + let id = id.into(); + let mut before = Vec::new(); + loop { + let frame = self.next(); + if frame["id"] == id && frame.get("method").is_none() { + return (frame, before); + } + before.push(frame); + } + } + + fn initialize(&mut self, versions: Value) -> Value { + let capabilities = if versions.is_null() { + json!({}) + } else { + json!({ "experimental": { "litheFunctions": { "versions": versions } } }) + }; + self.request( + 0, + "initialize", + json!({ "rootUri": "file:///tmp/state/", "capabilities": capabilities }), + ); + self.answer(0).0["result"].clone() + } + + fn invoke(&mut self, id: i64, function: &str, params: Value) { + self.request( + id, + "lithe/function/invoke", + json!({ "functionID": function, "operationID": format!("op-{id}"), "params": params }), + ); + } + + /// Ends the input stream and returns the backend's exit code. + fn close(mut self) -> i32 { + self.input = None; + self.exit.recv_timeout(WAIT).expect("the backend to end") + } + + fn exit_code(&self) -> i32 { + self.exit.recv_timeout(WAIT).expect("the backend to end") + } +} + +fn read_frame(reader: &mut impl BufRead) -> Option { + let mut length = None; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).ok()? == 0 { + return None; + } + let line = line.trim_end(); + if line.is_empty() { + break; + } + if let Some(value) = line.strip_prefix("Content-Length: ") { + length = value.parse().ok(); + } + } + let mut body = vec![0; length?]; + reader.read_exact(&mut body).ok()?; + serde_json::from_slice(&body).ok() +} + +fn echo_backend() -> Backend { + let mut backend = Backend::new("test-backend").version("1.2.3"); + backend + .function("echo.value", |params, _| Ok(params)) + .expect("valid function"); + backend +} + +#[test] +fn negotiates_the_newest_offered_version_and_lists_the_functions() { + for (offered, version) in [(json!([1, 2]), json!(2)), (json!([1]), json!(1))] { + let mut host = Host::start(echo_backend()); + let result = host.initialize(offered); + assert_eq!( + result, + json!({ + "capabilities": { "experimental": { + "litheFunctions": { "version": version, "functions": [{ "id": "echo.value" }] } + } }, + "serverInfo": { "name": "test-backend", "version": "1.2.3" } + }) + ); + host.invoke(1, "echo.value", json!({ "a": 1 })); + assert_eq!(host.answer(1).0["result"], json!({ "a": 1 })); + assert_eq!(host.close(), 1); + } +} + +#[test] +fn a_host_without_plugin_functions_gets_no_functions() { + let mut host = Host::start(echo_backend()); + assert_eq!( + host.initialize(Value::Null)["capabilities"], + json!({ "experimental": {} }) + ); + host.invoke(1, "echo.value", json!({})); + assert_eq!(host.answer(1).0["error"]["code"], -32602); + host.close(); +} + +#[test] +fn follows_the_lsp_lifecycle() { + let mut host = Host::start(echo_backend()); + host.invoke(1, "echo.value", json!({})); + assert_eq!(host.answer(1).0["error"]["code"], -32002); + host.send_raw(b"Content-Length: 3\r\n\r\nnot"); + let malformed = host.next(); + assert_eq!( + (malformed["id"].clone(), malformed["error"]["code"].clone()), + (Value::Null, json!(-32700)) + ); + host.send(json!([1, 2])); + assert_eq!(host.next()["error"]["code"], -32600); + + host.initialize(json!([1, 2])); + host.request(2, "initialize", json!({})); + assert_eq!(host.answer(2).0["error"]["code"], -32600); + host.request(3, "textDocument/hover", json!({})); + assert_eq!(host.answer(3).0["error"]["code"], -32601); + host.request(4, "shutdown", Value::Null); + assert_eq!( + host.answer(4).0, + json!({ "jsonrpc": "2.0", "id": 4, "result": null }) + ); + host.invoke(5, "echo.value", json!({})); + assert_eq!(host.answer(5).0["error"]["code"], -32600); + host.notify("exit", Value::Null); + assert_eq!(host.exit_code(), 0); +} + +#[test] +fn exit_without_shutdown_and_a_broken_frame_end_with_a_failure() { + let mut host = Host::start(echo_backend()); + host.notify("exit", Value::Null); + assert_eq!(host.exit_code(), 1); + + let mut host = Host::start(echo_backend()); + host.send_raw(b"Content-Length: nope\r\n\r\n"); + assert_eq!(host.exit_code(), 1); +} + +#[test] +fn maps_results_failures_and_panics() { + let mut backend = Backend::new("test-backend"); + backend + .function("work.run", |params, context| { + context.progress("Halfway", Some(150)); + match params["mode"].as_str() { + Some("plugin") => Err(PluginError::new("work.failed", "The work failed.").into()), + Some("internal") => Err(FunctionError::internal("/private/path: denied")), + Some("panic") => panic!("secret detail"), + Some("large") => Ok(json!("x".repeat(1 << 20))), + _ => Ok(json!({ "done": true })), + } + }) + .unwrap(); + let mut host = Host::start(backend); + host.initialize(json!([1])); + + host.invoke(1, "work.run", json!({})); + let (answer, before) = host.answer(1); + assert_eq!(answer["result"], json!({ "done": true })); + assert_eq!( + before, + [json!({ + "jsonrpc": "2.0", "method": "lithe/function/progress", + "params": { "operationID": "op-1", "message": "Halfway", "percentage": 100 } + })] + ); + + host.invoke(2, "work.run", json!({ "mode": "plugin" })); + assert_eq!( + host.answer(2).0["error"], + json!({ "code": -32000, "message": "The work failed.", "data": { "code": "work.failed" } }) + ); + for (id, mode) in [(3, "internal"), (4, "panic"), (5, "large")] { + host.invoke(id, "work.run", json!({ "mode": mode })); + assert_eq!( + host.answer(id).0["error"], + json!({ "code": -32603, "message": "The plugin backend failed unexpectedly." }), + "{mode}" + ); + } + // The session survives a panicking call. + host.invoke(6, "work.run", json!({})); + assert_eq!(host.answer(6).0["result"], json!({ "done": true })); + host.close(); +} + +#[test] +fn cancel_request_cancels_the_running_call() { + let mut backend = Backend::new("test-backend"); + backend + .function("work.wait", |_, context| { + context.progress("Waiting", None); + if context.cancellation().wait_timeout(Duration::from_secs(30)) { + context.check_cancelled()?; + } + Ok(json!("not cancelled")) + }) + .unwrap(); + let mut host = Host::start(backend); + host.initialize(json!([1])); + host.invoke(7, "work.wait", json!({})); + assert_eq!(host.next()["method"], "lithe/function/progress"); + host.notify("$/cancelRequest", json!({ "id": 7 })); + assert_eq!( + host.answer(7).0["error"], + json!({ "code": -32800, "message": "The request was cancelled." }) + ); + host.close(); +} + +#[test] +fn shutdown_cancels_running_calls() { + let mut backend = Backend::new("test-backend"); + let (sender, receiver) = mpsc::channel(); + backend + .function("work.wait", |_, context| { + context.progress("Waiting", None); + context.cancellation().wait_timeout(Duration::from_secs(30)); + context.check_cancelled()?; + Ok(Value::Null) + }) + .unwrap(); + backend.on_shutdown(move || sender.send("shutdown hook").unwrap()); + let mut host = Host::start(backend); + host.initialize(json!([1])); + host.invoke(1, "work.wait", json!({})); + assert_eq!(host.next()["method"], "lithe/function/progress"); + host.request(2, "shutdown", Value::Null); + let mut answers: Vec<_> = (0..2).map(|_| host.next()).collect(); + answers.sort_by_key(|answer| answer["id"].as_i64()); + assert_eq!(answers[0]["error"]["code"], -32800); + assert_eq!(answers[1]["result"], Value::Null); + assert_eq!(receiver.recv_timeout(WAIT), Ok("shutdown hook")); + host.close(); +} + +/// A function that stores, reads, and deletes a credential. +fn credentials_backend() -> Backend { + let mut backend = Backend::new("test-backend"); + backend + .function("secret.roundTrip", |params, context| { + let credentials = context.credentials(); + let key = params["key"].as_str().unwrap_or_default(); + if let Err(error) = credentials.set(key, "s3cr3t") { + return Err(PluginError::new("test.credentials", error.code().to_string()).into()); + } + let stored = credentials.get(key)?; + credentials.delete(key)?; + Ok(json!({ "stored": stored })) + }) + .unwrap(); + backend +} + +#[test] +fn credentials_are_host_requests_of_protocol_two() { + let mut host = Host::start(credentials_backend()); + host.initialize(json!([1, 2])); + host.invoke(1, "secret.roundTrip", json!({ "key": "db:main" })); + let set = host.next(); + assert_eq!(set["method"], "lithe/host/credentials/set"); + assert_eq!( + set["params"], + json!({ "key": "db:main", "secret": "s3cr3t" }) + ); + host.send(json!({ "jsonrpc": "2.0", "id": set["id"], "result": null })); + let get = host.next(); + assert_eq!(get["method"], "lithe/host/credentials/get"); + assert_eq!(get["params"], json!({ "key": "db:main" })); + host.send(json!({ "jsonrpc": "2.0", "id": get["id"], "result": { "secret": "s3cr3t" } })); + let delete = host.next(); + assert_eq!(delete["method"], "lithe/host/credentials/delete"); + host.send(json!({ "jsonrpc": "2.0", "id": delete["id"], "result": null })); + assert_eq!(host.answer(1).0["result"], json!({ "stored": "s3cr3t" })); + + // Core's refusal reaches the handler with its code. + host.invoke(2, "secret.roundTrip", json!({ "key": "db:main" })); + let set = host.next(); + host.send(json!({ + "jsonrpc": "2.0", "id": set["id"], + "error": { "code": -32601, "message": "Not granted.", "data": { "code": "plugin.host.permissionDenied" } } + })); + assert_eq!( + host.answer(2).0["error"]["message"], + "plugin.host.permissionDenied" + ); + + // A malformed key never reaches the host. + host.invoke(3, "secret.roundTrip", json!({ "key": "has space" })); + assert_eq!( + host.answer(3).0["error"]["message"], + "sdk.credentials.invalidKey" + ); + host.close(); +} + +#[test] +fn credentials_need_protocol_two() { + let mut host = Host::start(credentials_backend()); + host.initialize(json!([1])); + host.invoke(1, "secret.roundTrip", json!({ "key": "db:main" })); + let (answer, before) = host.answer(1); + assert!(before.is_empty(), "{before:?}"); + assert_eq!(answer["error"]["message"], "sdk.protocol.notNegotiated"); + host.close(); +} + +#[test] +fn cancelling_a_call_ends_its_waiting_host_request() { + let mut host = Host::start(credentials_backend()); + host.initialize(json!([2])); + host.invoke(1, "secret.roundTrip", json!({ "key": "db:main" })); + assert_eq!(host.next()["method"], "lithe/host/credentials/set"); + host.notify("$/cancelRequest", json!({ "id": 1 })); + assert_eq!(host.answer(1).0["error"]["code"], -32800); + host.close(); +} + +#[test] +fn functions_are_registered_and_withdrawn_at_runtime() { + let mut backend = Backend::new("test-backend"); + backend + .function("extra.add", |_, context| { + context + .backend() + .register_function("extra.value", |_, _| Ok(json!(42)))?; + Ok(json!(context.backend().function_ids())) + }) + .unwrap(); + backend + .function("extra.remove", |_, context| { + context.backend().unregister_function("extra.value")?; + Ok(Value::Null) + }) + .unwrap(); + let handle = backend.handle(); + let mut host = Host::start(backend); + host.initialize(json!([1])); + + host.invoke(1, "extra.add", json!({})); + let register = host.next(); + assert_eq!(register["method"], "lithe/function/register"); + assert_eq!( + register["params"], + json!({ "functions": [{ "id": "extra.value" }] }) + ); + host.send(json!({ "jsonrpc": "2.0", "id": register["id"], "result": null })); + assert_eq!( + host.answer(1).0["result"], + json!(["extra.add", "extra.remove", "extra.value"]) + ); + host.invoke(2, "extra.value", json!({})); + assert_eq!(host.answer(2).0["result"], 42); + + host.invoke(3, "extra.remove", json!({})); + let unregister = host.next(); + assert_eq!(unregister["params"], json!({ "ids": ["extra.value"] })); + host.send(json!({ "jsonrpc": "2.0", "id": unregister["id"], "result": null })); + assert_eq!(host.answer(3).0["result"], Value::Null); + host.invoke(4, "extra.value", json!({})); + assert_eq!(host.answer(4).0["error"]["code"], -32602); + + // A refused registration leaves the function out. + let registering = { + let handle = handle.clone(); + thread::spawn(move || handle.register_function("extra.refused", |_, _| Ok(Value::Null))) + }; + let register = host.next(); + host.send(json!({ + "jsonrpc": "2.0", "id": register["id"], + "error": { "code": -32602, "message": "No.", "data": { "code": "plugin.function.invalidRegistration" } } + })); + let refused: Result<(), SdkError> = registering.join().unwrap(); + assert_eq!( + refused.unwrap_err().code(), + "plugin.function.invalidRegistration" + ); + assert!(!handle.function_ids().contains(&"extra.refused".to_string())); + host.close(); + // After the session, requests fail instead of waiting. + assert_eq!( + handle + .register_function("extra.late", |_, _| Ok(Value::Null)) + .unwrap_err() + .code(), + "sdk.session.closed" + ); +} + +#[test] +fn function_declarations_are_checked() { + let mut backend = Backend::new("test-backend"); + let invalid = backend + .function("Bad", |_, _| Ok(Value::Null)) + .err() + .unwrap(); + assert_eq!(invalid.code(), "sdk.function.invalidID"); + backend.function("a.b", |_, _| Ok(Value::Null)).unwrap(); + let duplicate = backend + .function("a.b", |_, _| Ok(Value::Null)) + .err() + .unwrap(); + assert_eq!(duplicate.code(), "sdk.function.duplicate"); + assert_eq!( + backend + .handle() + .register_function("c.d", |_, _| Ok(Value::Null)) + .unwrap_err() + .code(), + "sdk.functions.unavailable" + ); +} + +#[test] +fn initialize_hooks_see_the_root_and_add_functions() { + let mut backend = Backend::new("test-backend"); + let (sender, receiver) = mpsc::channel(); + backend.on_initialize(move |initialize| { + sender.send(initialize.root_path()).unwrap(); + initialize.function("late.added", |_, _| Ok(json!("added")))?; + Ok(()) + }); + let mut host = Host::start(backend); + let result = host.initialize(json!([1, 2])); + assert_eq!( + result["capabilities"]["experimental"]["litheFunctions"]["functions"], + json!([{ "id": "late.added" }]) + ); + assert_eq!( + receiver.recv_timeout(WAIT).unwrap(), + Some(std::path::PathBuf::from("/tmp/state")) + ); + + let mut refusing = Backend::new("test-backend"); + refusing.on_initialize(|_| Err(PluginError::new("test.notReady", "Not ready.").into())); + let mut host = Host::start(refusing); + host.request(0, "initialize", json!({ "capabilities": {} })); + assert_eq!(host.answer(0).0["error"]["data"]["code"], "test.notReady"); + host.close(); +} From 2a835024539a676efdd4f55f5b8c4868e61ed4d7 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 12:39:43 -0700 Subject: [PATCH 3/3] contract: pin the mirror to Lithe d046fd1 (preview with the database plugin) Lithe preview merged 1lck/Lithe-IDEA#1470. The mirror now matches that merge commit: web bridge 2 file dialogs (dialog.openFile, dialog.saveFile) and the current Windows WebView2 status. Co-Authored-By: Claude Opus 5.5 --- contract.lock.json | 4 ++-- contract/v1/README.md | 22 ++++++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/contract.lock.json b/contract.lock.json index e10f0ff..b3fc27b 100644 --- a/contract.lock.json +++ b/contract.lock.json @@ -3,12 +3,12 @@ "source": { "repository": "https://github.com/1lck/Lithe-IDEA", "directory": "shared/contracts/plugins/v1", - "revision": "ce175e0ebb1a21f1876cf28b42fb8e47725a4b76" + "revision": "d046fd1768d00a52b1fc99d45067d06cf535bc23" }, "files": [ { "path": "README.md", - "sha256": "cd73946b46c93875a9fb5015be95a733b2ad7a6d2efbf4077be473609ba87251" + "sha256": "87b6738fdd0d764b93f33556992d0586dfb97e68abb58e0bc3a5ac690ec97aab" }, { "path": "fixtures/backend.plugin.json", diff --git a/contract/v1/README.md b/contract/v1/README.md index 92bb546..3cf6a4b 100644 --- a/contract/v1/README.md +++ b/contract/v1/README.md @@ -1582,8 +1582,10 @@ Hosts load a page under these rules: - The root element carries `data-theme` (`light` or `dark`) and the host's `--lithe-*` CSS colour and font variables, updated with the theme. -Before the page's scripts run, the host defines `window.lithe` (bridge -version 1): `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, +Before the page's scripts run, the host defines `window.lithe`: +`lithe.version`, the bridge version (2 on current macOS hosts; version 2 +adds the file dialogs to version 1, so a page checks it before offering +them), `lithe.context` (`pluginID`, `windowID`, `platform`, `locale`, `theme`), `lithe.request(method, params)`, which returns a promise of the result or rejects with `{ code, message }`, and `lithe.on(event, listener)`, which returns an unsubscribe function. A page reaches only its own plugin's @@ -1605,6 +1607,8 @@ their output appears in the host's Build output window. | `functions.list` | `{ instance? }` | `{ functions }`: sorted IDs of the functions the instance's provider, or without `instance` the plugin's [backend](#plugin-backend), currently offers; starts that process | | `functions.invoke` | `{ instance?, function, params?, call? }`, without `instance` a call to the plugin's backend; `params` an object, `call` a page-chosen ID of at most 128 characters that the page never used before | `{ value }`, the function's result | | `functions.cancel` | `{ call }` | `{}`; a call still awaiting consent never starts, a call waiting for its provider to start ends at once while the start goes on, and a call that already ended is ignored | +| `dialog.openFile` (bridge 2) | `{ title?, extensions? }` | `{ path }`: the absolute path of the file the user chose, or `null` when the dialog was cancelled | +| `dialog.saveFile` (bridge 2) | `{ title?, suggestedName?, extensions? }` | `{ path }` as for `dialog.openFile`; the host has asked before replacing an existing file, and nothing is written | | Event | Payload | | --- | --- | @@ -1613,6 +1617,13 @@ their output appears in the host's Build output window. | `functions.progress` | `{ call, instance?, function, message?, percentage? }`, only for a call made with `call`; `instance` absent for a backend call | | `ui.themeChanged` | `{ theme }` | +The file dialogs are the host's: they name the plugin that asks, a page +has at most one open at a time, and the page learns only the path the user +chose. `extensions` are up to 16 file name extensions without the dot; +`suggestedName` is a file name, not a path. A page passes the path to its +backend, which reads or writes the file; the page itself still reaches no +file. + Bridge errors are `plugin.web.unknownMethod`, `plugin.web.invalidRequest`, and `plugin.web.forbidden`. Build requests also report the `build.*` codes. Function requests report Core's codes unchanged (`plugin.function.*`, @@ -1632,8 +1643,11 @@ workspace, it holds until the project is closed. Calls to the plugin's backend ask for none; the install review covered it. macOS implements web content with WKWebView and advertises `ui: [1, 2, 3]` (including the native controls described below). -Windows has no WebView2 host yet and still advertises `ui: [1]`, so Core refuses -`ui` 2 plugins there with `plugin.api.unsupported`. +Windows implements web content with WebView2 under a plugin-private +`lithe-plugin://` origin and also advertises `ui: [1, 2]`; its bridge v0 +answers `ui.getState`, `ui.setState`, and `workspace.openFile`, and returns +`plugin.web.unknownMethod` for `build.*` and `functions.*` until the Windows +build-system capability lands. Windows does not advertise `ui` 3. ### Future public extension direction (not implemented in API level 2)