From eb7020f9b99261cee410c385ff5f634cc47d2ced Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:38:28 -0700 Subject: [PATCH 01/28] docs(plugins): add marketplace plan --- docs/dev/plugin-marketplace-plan.md | 388 ++++++++++++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 docs/dev/plugin-marketplace-plan.md diff --git a/docs/dev/plugin-marketplace-plan.md b/docs/dev/plugin-marketplace-plan.md new file mode 100644 index 00000000..597b8f98 --- /dev/null +++ b/docs/dev/plugin-marketplace-plan.md @@ -0,0 +1,388 @@ +# Plugin marketplace plan + +## Decision + +Build a separate public `qrate-plugin-registry` repository for curated plugin metadata and +release verification. Keep the public website and documentation on qrate's existing `site` +branch. Keep each plugin's source and releases in its author's repository. + +The application installs HTTPS release artifacts with immutable versions and hashes. It does not +run `git clone`. The direct-install field accepts a GitHub repository or release URL, so users can +still paste the link authors share; qrate resolves it to a versioned release artifact. + +This leaves the Lua runtime model simple while giving the marketplace a clear review and trust +boundary. + +## Goals + +- Let users discover curated qrate plugins on the website and in qrate. +- Let an author submit a public plugin through GitHub issues and pull requests. +- Install and update reviewed release packages without requiring Git on a user's machine. +- Permit direct installation of an unlisted public GitHub plugin, with an explicit trust warning. +- Use browser install buttons that open qrate at a confirmation screen. +- Preserve manual folder installation for local, institutional, offline, and development plugins. +- Make package identity, compatibility, integrity, permission changes, and revocation explicit. + +## Non-goals for the first release + +- A package manager for Luau dependencies. +- Generic Git remotes, SSH URLs, private repositories, branches, commits, submodules, or Git LFS. +- Automatically enabling newly installed plugins. +- Automatically installing plugin updates. +- A hosted database, user account system, payment system, or package-upload service. +- Claiming that an official listing makes third-party code safe. + +## Existing constraints + +Plugins are currently ordinary files in qrate's application-data plugins directory. A plugin is +either a single `.lua` file or `/init.lua`, and a folder plugin can require sibling Lua +modules. This is the runtime package format and should remain so. + +The current Lua table returned by `init.lua` is the runtime descriptor. It is not appropriate +marketplace metadata because discovering a descriptor would require executing untrusted Lua. + +The plugin host currently recognizes API version 1 and `net` as its one permission. The installer +must validate API compatibility before a plugin is installed; the runtime remains authoritative for +the permissions a loaded plugin actually declares. + +The updater already verifies Ed25519-signed metadata, semantic versions, and SHA-256 artifact +hashes. Plugin distribution should reuse those trust concepts and implementation patterns, with a +separate plugin-registry signing key. + +The `site` branch is already an isolated Astro project with its own deployment and plugin +documentation. There is no current benefit to moving it before marketplace work starts. + +## Repository boundaries + +| Repository or branch | Owns | Does not own | +| --- | --- | --- | +| `qrate` `main` | Desktop installer, plugin host, install UI, deep links, embedded registry public key | Curated listings and third-party source | +| `qrate-plugin-template` | Canonical Lua API definitions, example plugin, author package tooling | Registry data | +| Plugin author repositories | Plugin source, tests, release artifacts, support | Official discoverability by itself | +| `qrate-plugin-registry` | Curated records, schemas, validation CI, catalog signatures, submission policy | Website implementation or plugin source | +| `qrate` `site` branch | Marketplace pages, documentation, install links, rendering registry data | Trust decisions in the app binary | + +### Why the registry is a separate repository + +The registry needs an independent contributor and review boundary. A marketplace listing is a +reviewed statement about a particular release artifact, its hash, compatibility, publisher, and +permissions. It should not be entangled with website styling, Rust application work, or the source +history of independent plugins. + +One static signed catalog can serve both qrate and the website. This avoids an application-owned +database and gives maintainers an auditable pull-request history for every listed version. + +### Alternatives rejected + +| Alternative | Why not | +| --- | --- | +| Store registry entries in the `site` branch | Couples catalog review and signing to website changes and deployment; makes contributor permissions less clear | +| Move the site to `qrate-site` immediately | Adds migration work but no present technical advantage; revisit when ownership or deployment needs diverge | +| A central repository holding all plugin source | Creates false ownership of third-party plugins and couples unrelated release schedules | +| GitHub topic/search as discovery | Search cannot pin a reviewed artifact, verify integrity, express compatibility, or revoke a release | +| Clone arbitrary Git URLs from qrate | Depends on Git or a large Git library and introduces mutable refs, credentials, SSH, LFS, submodules, and interactive failure modes | + +Move the `site` branch into a separate repository only when site ownership, site issue triage, +release cadence, branch protections, or a substantial server component make the branch an actual +operational constraint. + +## Package format + +Each distributable release includes a non-executable manifest at its package root: + +```json +{ + "schema": 1, + "id": "org.islandora.vocabularies", + "name": "Islandora vocabularies", + "version": "1.2.0", + "api_version": 1, + "entry": "init.lua", + "description": "Checks and suggests Islandora taxonomy terms.", + "homepage": "https://github.com/devnull03/qrate-islandora-plugin", + "license": "MIT", + "permissions": ["net"] +} +``` + +`qrate-plugin.json` is distribution metadata, not a replacement for the Lua descriptor: + +- `id` is a stable reverse-domain-style distribution identity, distinct from a local folder name. +- `version` is SemVer and matches the release record. +- `api_version` tells the installer what host version the package needs. +- `entry` is a safe relative path under the package root. +- `permissions` lets qrate show an install preview but must agree with the loaded descriptor. +- The Lua descriptor remains the runtime source of hook, setting, and command declarations. + +Authors publish an explicit versioned archive such as +`qrate-islandora-vocabularies-1.2.0.zip`. The archive has one plugin root and contains its +manifest, entry file, sibling Lua modules, README, license, and optional editor types. + +Do not use GitHub-generated "Source code (zip)" downloads as the package contract. A GitHub Release +asset has an intentional layout and can be checked against the hash in the registry. + +## Catalog and trust + +The registry publishes these static files: + +```text +catalog.json +catalog.json.sig +``` + +The app embeds the registry's Ed25519 public key. It downloads the catalog, verifies its signature, +validates its schema, and caches only the last valid result. A record contains: + +- distribution ID, name, description, categories, icon/screenshots, license, and support links; +- publisher GitHub account or organization and source repository; +- exact release version and publication time; +- immutable artifact URL, byte size, and SHA-256; +- supported qrate plugin API version; +- declared permissions; +- release notes URL; +- optional revocation status and replacement recommendation. + +The registry's private signing key belongs in a protected GitHub Actions environment requiring +maintainer approval, never in a repository. Catalogs include a key ID so that key rotation can be +explicitly supported. + +"Official catalog" means the release metadata and artifact hash were reviewed and published in the +signed catalog. It does not mean qrate guarantees that a plugin is harmless. + +## Acquisition paths + +| Path | Input | Trust state | Update source | +| --- | --- | --- | --- | +| Official catalog | Search or browse result | Signed catalog and artifact hash | Registry | +| Direct GitHub release | Repository or release URL | Unlisted, user-confirmed source | That repository's releases | +| Manual folder | Files copied or cloned by the user | Unmanaged local code | No qrate update action | + +For direct installation, qrate accepts an ordinary public GitHub repository URL or GitHub Release +URL. It resolves a release asset, then shows the author, repository, exact version, artifact URL, +SHA-256, permissions, and an unlisted-source warning before it downloads anything. + +V1 supports public GitHub releases only. This keeps the workflow familiar without requiring a Git +executable. Generic `git+https`, SSH, branch, commit, private-repository, submodule, and LFS +support must be designed as a later source-provider feature if actual user needs justify it. + +## Safe install and update lifecycle + +Managed source files live beneath qrate application data, while a qrate-owned receipt lives outside +the package source: + +```text +/plugins/ + org.islandora.vocabularies/ + qrate-plugin.json + init.lua + ... +/plugin-installs/ + org.islandora.vocabularies.json +``` + +The receipt records the distribution ID, installed version, source class, source identity, +artifact hash, and install date. + +Install and update steps: + +1. Download to a temporary file with a configured size limit. +2. Verify byte size and SHA-256 before extraction. +3. Extract into a unique temporary directory. +4. Reject absolute paths, `..` traversal, symlinks, duplicate roots, oversized expansion, malformed + manifests, and invalid entry paths. +5. Validate manifest schema and API compatibility. +6. Atomically replace the managed installation directory, retaining the prior version until a + successful reload. +7. Reload through the existing plugin reload lifecycle. +8. Write the receipt only after the new package is valid. + +The installer validates that static manifest identity and permission metadata agree with the loaded +Lua descriptor. A mismatch is an install failure, not a warning. + +First-time installs are disabled by default. Installation and runtime permission grants remain +separate choices. A direct source must receive a stronger warning in the UI. + +Updates never replace manual/unmanaged folders. They are rejected when they require a newer host API +than the running qrate. Existing enablement may be retained for the same verified publisher/source +identity. Existing permission grants are retained only if the new version requests the same or +fewer permissions. New permissions remain ungranted until the user explicitly approves them. + +If download, extraction, validation, or reload fails, qrate leaves the previous package usable. + +## Revocation + +Catalog records support a `revoked` state, reason, and replacement recommendation. A signed catalog +update can then stop new installs and warn users of a known bad installed version without waiting +for a qrate application release. + +The initial policy should warn and prevent new installations. Automatic disabling is reserved for a +documented emergency policy, because qrate is used in offline and institutional environments. +qrate must never delete a plugin or its data automatically. + +## Application UX + +Extend **Extensions** with: + +```text +Discover plugins… +Install plugin from link… +Manage plugins… +──────────────────── +Plugins Folder +Reload Plugins +``` + +The discover view provides search, categories, official/unlisted/installed state, package details, +compatibility, requested permissions, source, license, release notes, and install/update actions. +Only signed catalog records receive an Official catalog badge. + +The install-from-link flow is: + +1. User pastes a GitHub repository or release URL. +2. qrate resolves a selectable release artifact. +3. The confirmation screen shows source, publisher, version, immutable URL, hash, description, + permissions, and an unlisted-source warning. +4. The user selects **Install**. +5. qrate installs the disabled plugin and takes the user to plugin settings. + +Settings remains the single source of truth for enabling a plugin and granting runtime +permissions. Extend the existing Plugins settings page with source, installed version, update +availability, integrity state, and managed/unmanaged status rather than creating a competing +control surface. + +Removal only deletes packages proven to be qrate-managed. It must never remove an arbitrary manual +folder discovered in the plugins directory. + +## Browser-to-app links + +The website can use a `qrate` URI scheme: + +```text +qrate://plugin/install?source=registry&id=org.islandora.vocabularies +qrate://plugin/install?source=github&repo=devnull03/qrate-islandora-plugin +qrate://plugin/install?source=url&release=https%3A%2F%2Fgithub.com%2F... +``` + +The app strictly validates source types, identifiers, and URLs. Links contain no credentials, file +paths, shell arguments, or JavaScript. Opening a link always goes to the installation review +screen; it never silently downloads, installs, enables, or grants permission to a plugin. + +Each website install button also has a browser fallback: source/repository link and manual +installation instructions for people without qrate or with protocol handlers disabled. + +Platform integration: + +| Platform | Work | +| --- | --- | +| Windows | Add `qrate` protocol registration to NSIS with safely quoted executable and argument handling | +| macOS | Add `CFBundleURLTypes` for `qrate` in the app bundle | +| Linux | Install a desktop entry for `x-scheme-handler/qrate` and update the desktop database where packaging permits | + +The protocol handler alone is insufficient: clicking a browser link launches a new process even +when qrate is open. The feature therefore includes a per-user single-instance handoff: + +1. The primary process owns a local endpoint. +2. A later invocation validates and forwards the deep link, focuses the primary window, and exits. +3. The primary process opens the install review. +4. A stale endpoint is safely recovered. + +Use Windows named pipes and Unix-domain sockets, or a vetted cross-platform single-instance +implementation. Do not expose a network TCP listener. + +## Submission workflow + +1. An author starts from `qrate-plugin-template`. +2. The author develops and tests in their own public repository. +3. The author produces a versioned plugin archive and GitHub Release. +4. The author opens a pull request to `qrate-plugin-registry` adding a plugin or version record. +5. Registry CI downloads the release asset and validates hash, size, archive structure, manifest, + API compatibility, and declared permissions. +6. Maintainers review the source and machine-generated report. +7. Merge regenerates, signs, and publishes the catalog and triggers a site refresh. + +Use a GitHub issue form for early listing requests and author support, and a pull request for the +authoritative published record. A PR is required because it precisely pins and reviews the metadata +the application consumes. + +The registry needs `CONTRIBUTING.md`, `SECURITY.md`, `PUBLISHING.md`, `SCHEMA.md`, issue and PR +templates, a maintainer checklist, and explicit criteria for removing or revoking listings. + +## Implementation phases + +### Phase 0: contracts and security fixtures + +- Write an architecture decision record in qrate. +- Specify the static package manifest and signed catalog schemas. +- Create valid and hostile archive fixtures: traversal, symlink, duplicate root, oversized archive, + malformed manifest, unsupported API, manifest/descriptor mismatch, and bad hash. +- Decide registry ownership, public naming, signing custody, and revocation policy. + +Exit criterion: schemas and fixtures have been reviewed before download/install UI code starts. + +### Phase 1: registry and author release tooling + +- Create `qrate-plugin-registry` with schema validation, artifact validation CI, catalog signing, + submission policy, and templates. +- Add package manifest and release-asset tooling to `qrate-plugin-template`. +- Package Islandora through the same process as the first real registry entry. +- Update template and Islandora documentation under the existing multi-repository plugin API rule. + +Exit criterion: a maintainer can merge one registry PR and produce a signed catalog without editing +the website data by hand. + +### Phase 2: installer foundation + +- Add a focused qrate package-distribution module or crate; do not combine downloader and archive + policy with the Lua runtime. +- Implement catalog verification/cache, download, extraction, manifest checks, receipts, atomic + replacement, rollback, and managed/unmanaged detection. +- Reuse updater trust primitives where that avoids duplicated security logic, with a separate key + and artifact domain. +- Remove the development working-directory plugin fallback from packaged builds. +- Test all Phase 0 fixtures through integration tests. + +Exit criterion: a local test server can exercise successful install, rejection, update, rollback, +and recovery without UI automation. + +### Phase 3: qrate UI + +- Add discovery, install-from-link, update, error, and recovery UI. +- Extend the existing Plugins settings page with installed-package metadata. +- Add compatible-update and permission-delta confirmation. +- Test cancellation, network failure, restart during installation, broken updates, manual-folder + conflicts, and downgrade/reinstall behavior. + +Exit criterion: a packaged qrate build installs from both the official catalog and a direct GitHub +release without Git installed. + +### Phase 4: deep links and website + +- Add secure URI parsing, initial-launch processing, and existing-process handoff. +- Register the URI scheme in Windows, macOS, and supported Linux packaging. +- Add `/plugins` browse, search, and detail pages to the site branch, rendered from registry data. +- Add install buttons and non-protocol browser fallback. +- Rebuild the site when a signed registry catalog is published. + +Exit criterion: a link works identically when qrate is closed and when it is already open. + +### Phase 5: rollout and operations + +- Launch with Islandora and one small additional test/community plugin. +- Describe direct installations as unreviewed and catalog listings as officially listed. +- Rehearse catalog key rotation and a release revocation. +- Monitor installation failures, release-author friction, and support volume. +- Decide whether catalog refresh is automatic and whether update checks follow qrate's application + update preference. Installation remains user-confirmed in the first release. + +Exit criterion: contributors and maintainers can follow tested publication and incident-response +runbooks. + +## Decisions still to confirm + +1. Create the public repository as `devnull03/qrate-plugin-registry`. +2. Use **Official catalog** or **Listed by qrate**, never "verified safe", in product language. +3. Make V1 updates manual-confirmation only. +4. Limit direct V1 sources to public GitHub releases. +5. Fully support Windows and macOS deep links; decide whether Linux is a release requirement or + documented packaging-dependent support. +6. Keep the signing key in a protected GitHub Actions environment with required maintainer approval. From 834f482e342fe266875b9d7859d657a7a220d357 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:17:39 -0700 Subject: [PATCH 02/28] docs(plugins): detail marketplace repository work --- docs/dev/plugin-marketplace-plan.md | 466 +++++++++++++++++++++++++++- 1 file changed, 449 insertions(+), 17 deletions(-) diff --git a/docs/dev/plugin-marketplace-plan.md b/docs/dev/plugin-marketplace-plan.md index 597b8f98..caf5406a 100644 --- a/docs/dev/plugin-marketplace-plan.md +++ b/docs/dev/plugin-marketplace-plan.md @@ -6,9 +6,9 @@ Build a separate public `qrate-plugin-registry` repository for curated plugin me release verification. Keep the public website and documentation on qrate's existing `site` branch. Keep each plugin's source and releases in its author's repository. -The application installs HTTPS release artifacts with immutable versions and hashes. It does not +The application installs versioned HTTPS release artifacts pinned by size and SHA-256. It does not run `git clone`. The direct-install field accepts a GitHub repository or release URL, so users can -still paste the link authors share; qrate resolves it to a versioned release artifact. +still paste the link authors share. qrate resolves it to a versioned release artifact. This leaves the Lua runtime model simple while giving the marketplace a clear review and trust boundary. @@ -56,11 +56,11 @@ documentation. There is no current benefit to moving it before marketplace work | Repository or branch | Owns | Does not own | | --- | --- | --- | -| `qrate` `main` | Desktop installer, plugin host, install UI, deep links, embedded registry public key | Curated listings and third-party source | -| `qrate-plugin-template` | Canonical Lua API definitions, example plugin, author package tooling | Registry data | -| Plugin author repositories | Plugin source, tests, release artifacts, support | Official discoverability by itself | -| `qrate-plugin-registry` | Curated records, schemas, validation CI, catalog signatures, submission policy | Website implementation or plugin source | -| `qrate` `site` branch | Marketplace pages, documentation, install links, rendering registry data | Trust decisions in the app binary | +| `qrate` `main` | Desktop installer, plugin host, install UI, deep links, embedded registry public key, user docs | Curated listings and third-party source | +| `qrate-plugin-template` | Canonical Lua API definitions, example plugin, package manifest, author release tooling | Registry data | +| Plugin author repositories | Plugin source, tests, release artifacts, release notes, support | Official discoverability by itself | +| `qrate-plugin-registry` | Curated records, schemas, validation CI, signed catalog, submission and incident policy | Website implementation or plugin source | +| `qrate` `site` branch | Marketplace pages, author guides, install links, and build-time catalog rendering | Catalog signing or plugin review | ### Why the registry is a separate repository @@ -115,12 +115,15 @@ Each distributable release includes a non-executable manifest at its package roo - The Lua descriptor remains the runtime source of hook, setting, and command declarations. Authors publish an explicit versioned archive such as -`qrate-islandora-vocabularies-1.2.0.zip`. The archive has one plugin root and contains its -manifest, entry file, sibling Lua modules, README, license, and optional editor types. +`qrate-islandora-vocabularies-1.2.0.zip`. The archive contains its manifest, entry file, sibling Lua +modules, README, license, and optional editor types at the archive root. Do not use GitHub-generated "Source code (zip)" downloads as the package contract. A GitHub Release asset has an intentional layout and can be checked against the hash in the registry. +GitHub lets a publisher replace a release asset. The URL is not the content identity. The registry +pins the reviewed bytes with the SHA-256 and size. The app rejects replacement bytes at that URL. + ## Catalog and trust The registry publishes these static files: @@ -136,7 +139,7 @@ validates its schema, and caches only the last valid result. A record contains: - distribution ID, name, description, categories, icon/screenshots, license, and support links; - publisher GitHub account or organization and source repository; - exact release version and publication time; -- immutable artifact URL, byte size, and SHA-256; +- exact release asset URL, byte size, and SHA-256; - supported qrate plugin API version; - declared permissions; - release notes URL; @@ -146,6 +149,21 @@ The registry's private signing key belongs in a protected GitHub Actions environ maintainer approval, never in a repository. Catalogs include a key ID so that key rotation can be explicitly supported. +`catalog.json.sig` is a small JSON document: + +```json +{ + "schema": 1, + "key_id": "qrate-plugin-catalog-1", + "algorithm": "Ed25519", + "sha256": "", + "signature_base64": "" +} +``` + +The app and site reject an unknown key ID or algorithm. They compare the hash and verify the +signature before they parse `catalog.json`. + "Official catalog" means the release metadata and artifact hash were reviewed and published in the signed catalog. It does not mean qrate guarantees that a plugin is harmless. @@ -158,8 +176,9 @@ signed catalog. It does not mean qrate guarantees that a plugin is harmless. | Manual folder | Files copied or cloned by the user | Unmanaged local code | No qrate update action | For direct installation, qrate accepts an ordinary public GitHub repository URL or GitHub Release -URL. It resolves a release asset, then shows the author, repository, exact version, artifact URL, -SHA-256, permissions, and an unlisted-source warning before it downloads anything. +URL. It resolves a release asset and shows the source, version, and an unlisted-source warning. +After the user selects **Review package**, qrate downloads the archive without extracting or running +it. The final review shows the computed SHA-256 and static manifest before installation. V1 supports public GitHub releases only. This keeps the workflow familiar without requiring a Git executable. Generic `git+https`, SSH, branch, commit, private-repository, submodule, and LFS @@ -238,12 +257,14 @@ Only signed catalog records receive an Official catalog badge. The install-from-link flow is: -1. User pastes a GitHub repository or release URL. +1. The user pastes a GitHub repository or release URL. 2. qrate resolves a selectable release artifact. -3. The confirmation screen shows source, publisher, version, immutable URL, hash, description, - permissions, and an unlisted-source warning. -4. The user selects **Install**. -5. qrate installs the disabled plugin and takes the user to plugin settings. +3. The source screen shows the publisher, version, asset URL, and an unlisted-source warning. +4. The user selects **Review package**. +5. qrate downloads the archive but does not extract or run it. +6. The review screen shows the computed hash, description, compatibility, and permissions. +7. The user selects **Install**. +8. qrate installs the disabled plugin and opens its settings. Settings remains the single source of truth for enabling a plugin and granting runtime permissions. Extend the existing Plugins settings page with source, installed version, update @@ -307,6 +328,417 @@ the application consumes. The registry needs `CONTRIBUTING.md`, `SECURITY.md`, `PUBLISHING.md`, `SCHEMA.md`, issue and PR templates, a maintainer checklist, and explicit criteria for removing or revoking listings. +## Repository implementation plans + +The repositories share contracts, but each repository has one clear job. Each subsection below can +become an issue or a small issue group in that repository. + +### `qrate-plugin-registry`: catalog source and publication + +Create `devnull03/qrate-plugin-registry` as a public repository. Use this initial layout: + +```text +.github/ + ISSUE_TEMPLATE/ + listing-request.yml + security-report.yml + workflows/ + validate.yml + publish.yml + pull_request_template.md +plugins/ + org.islandora.vocabularies.json +schemas/ + catalog.schema.json + listing.schema.json + package.schema.json +scripts/ + build-catalog.mjs + validate-artifact.mjs + validate-records.mjs +test/ + fixtures/ + packages/ + records/ +CONTRIBUTING.md +LICENSE +PUBLISHING.md +README.md +SCHEMA.md +SECURITY.md +package.json +``` + +Keep one source record in `plugins/.json`. The file contains listing metadata and a +version history. It does not copy the plugin README or Lua descriptor. + +Each version record pins these fields: + +- version and publication date; +- plugin API version; +- release page and release asset URLs; +- SHA-256 and byte size; +- package manifest fields needed for search and install review; +- review state, revocation state, and replacement version if one exists. + +Use `schemas/package.schema.json` as the source of truth for `qrate-plugin.json`. Copy or generate +the same schema for qrate tests and template checks. Store the schema version in each document. + +The pull-request workflow performs these checks: + +1. Validate all source records against `listing.schema.json`. +2. Reject duplicate distribution IDs, versions, release assets, and normalized repository URLs. +3. Download each new or changed release asset with time and size limits. +4. Verify the submitted SHA-256 and byte size. +5. Inspect the ZIP without running Lua. +6. Reject path traversal, symlinks, duplicate names, invalid roots, and expansion limit violations. +7. Validate `qrate-plugin.json` against `package.schema.json`. +8. Compare the package manifest with the registry version record. +9. Confirm that the release belongs to the declared public GitHub repository. +10. Build an unsigned catalog and validate it against `catalog.schema.json`. +11. Attach a short validation report to the pull request. + +The first version can use Node and a small set of pinned packages. Use the same validator modules in +pull-request CI and publication CI. Do not maintain a second validation implementation in workflow +YAML. + +The publication workflow runs after a protected `main` merge. It performs these actions: + +1. Repeat all record and artifact checks. +2. Build `dist/catalog.json` with a stable record order and stable JSON serialization. +3. Sign the exact catalog bytes with the registry Ed25519 key. +4. Write `dist/catalog.json.sig` and a small `dist/status.json`. +5. Publish `dist/` to GitHub Pages at a stable HTTPS URL. +6. Create a deployment record that contains the source commit. +7. Trigger a Cloudflare site rebuild. + +Use a protected `catalog-production` environment for the private signing key and site deploy hook. +Require maintainer approval for the publication job. Give the workflow read-only repository access +plus only the Pages and deployment permissions it needs. + +`status.json` contains the catalog schema, generation time, source commit, key ID, and catalog hash. +The site can show this information in an error page. qrate does not trust `status.json`. + +The repository policy must define: + +- minimum source and release requirements; +- allowed licenses and required license files; +- stable publisher and distribution identity rules; +- review rules for new plugins and new versions; +- permission-change review; +- abandoned-plugin and transfer rules; +- removal, revocation, appeal, and compromised-release steps; +- key rotation and emergency catalog publication. + +Do not accept a listing through an issue alone. The issue form helps an author prepare a pull +request. The pull request remains the reviewed change that publication consumes. + +### `qrate-plugin-template`: author contract and release tooling + +The template currently has five tracked files. It has no tests, package manifest, release workflow, +or archive tool. The current `init.lua` comment also calls the runtime descriptor a manifest. Change +that term before the static package manifest is introduced. + +Add these files: + +```text +.github/ + workflows/ + check.yml + release.yml +qrate-plugin.json +scripts/ + package-plugin.mjs +test/ + manifest.test.mjs +LICENSE +``` + +Keep the template small. Do not turn it into a JavaScript application. The Node script can use the +standard library and the local Git executable. It needs no runtime dependency. + +`qrate-plugin.json` contains obvious placeholder values that an author must change. The distribution +ID must not use the local folder name. The README must explain how authors choose and retain a +stable ID. + +`scripts/package-plugin.mjs` performs these actions: + +1. Validate the manifest fields and SemVer version. +2. Confirm that `entry` names a tracked file under the repository root. +3. Confirm that required README and license files exist. +4. Collect tracked package files from an explicit allowlist. +5. Create `dist/-.zip` with files at the ZIP root. +6. Print and write the SHA-256 and byte size. +7. Fail if the worktree has package changes that are not committed. + +The allowlist includes the manifest, entry file, Lua modules, types, README, license, and plugin +assets. It excludes `.git`, workflows, tests, local logs, and `dist`. + +The check workflow runs on pushes and pull requests. It validates the manifest, runs the package +script in check mode, and runs Luau type checks. Pin the Luau tool version. + +The release workflow runs on a `v*` tag. It requires an exact match between the tag and manifest +version. It creates the ZIP and checksum, then publishes both as GitHub Release assets. It must fail +instead of replacing an asset on an existing release. + +Update the README with this author path: + +1. Create a repository from the GitHub template button. +2. Set the distribution ID, name, version, repository, license, and permissions. +3. Develop against `types/qrate.lua`. +4. Test the plugin in qrate as an unmanaged folder. +5. Run the local package check. +6. Tag the release. +7. Inspect the generated release assets. +8. Submit the release to the registry. + +Keep `types/qrate.lua` as the canonical plugin API copy. Marketplace metadata does not change the +runtime API. A package schema change does not require a plugin API version increase. + +### Plugin author repositories: release ownership + +Each plugin repository owns its source and support process. The registry never becomes a source +mirror. + +Authors must: + +- keep one stable distribution ID; +- publish a static package manifest; +- publish a versioned ZIP and checksum; +- keep old reviewed assets available; +- document requested permissions and data use; +- link release notes and a support or issue page; +- report a compromised release through the registry security process. + +The first-party Islandora plugin is the end-to-end pilot. Apply the template files to that repository +without replacing its existing tests or documentation. Publish a new release through the same +workflow that community authors will use. Do not seed the catalog with a hand-built special case. + +The pilot must test a folder plugin with sibling modules, network permission, user settings, project +settings, and a larger real package. Add a second small plugin before launch to test a no-permission +package and a different publisher. + +### `qrate` `main`: package manager, UI, and operating-system integration + +Create a focused `plugin-package` crate. Keep package download and installation policy out of +`plugin-host`. The host continues to load installed Lua and enforce runtime permissions. + +The crate owns: + +- signed catalog parsing and verification; +- catalog cache and refresh state; +- GitHub release URL parsing and direct-release metadata; +- bounded downloads and SHA-256 checks; +- safe ZIP inspection and extraction; +- package manifest validation; +- install receipts and managed installation paths; +- atomic install, update, rollback, and removal operations; +- compatibility, source identity, and permission delta checks. + +Expose typed operations to the app. Do not expose raw archive paths or partly installed directories. +Use one error type that keeps a safe user message and a detailed log cause. + +Reuse the updater's Ed25519 envelope rules, SemVer checks, SHA-256 helper, and atomic JSON write +patterns where their contracts match. Use a separate registry key ID and domain types. Do not make a +plugin package look like an application update. + +Update `plugin-host` to return enough identity for the package manager to match a loaded plugin with +its receipt. Keep unmanaged folder discovery. Restrict the working-directory `./plugins` path to +development builds so a packaged app has one production plugin directory. + +Add these application surfaces: + +- `DiscoverPlugins` and `InstallPluginFromLink` actions in both Extensions menu implementations; +- a discovery window or workspace panel that uses the signed catalog cache; +- a source screen and package review screen for direct GitHub installs; +- managed source, version, integrity, update, and remove fields on the existing Plugins settings page; +- update and revocation notices that link to release or incident details; +- recovery actions for failed reloads and retained rollback copies. + +Keep enablement and permission grants in the existing Plugins settings page. An install button must +not enable a plugin or grant `net`. + +Update `docs/plugins/index.md` with official, direct, and manual install paths. Update +`docs/plugins/api-reference.md` only when the runtime API changes. Add package and submission links +to `CONTRIBUTING.md` and the root README. Update the three-repository rule in `CLAUDE.md` so it names +the registry schema and site marketplace. + +The app startup path must parse a `qrate://` argument before it opens the normal window. Add one +strict URI parser with unit tests. Both initial launch and existing-process handoff must use the same +parsed command type. + +Package integration includes: + +- NSIS URL protocol keys and uninstall cleanup; +- MSI protocol registration for managed Windows deployments; +- `CFBundleURLTypes` in the macOS bundle; +- `x-scheme-handler/qrate` in the Linux desktop file; +- a local per-user handoff for a second qrate process; +- focus and install-review routing in the primary process. + +Portable builds cannot reliably register a protocol handler. Document manual registration as +unsupported in V1 and keep the website fallback visible. + +Add test layers: + +1. Unit tests cover URI, schema, version, source identity, and permission delta rules. +2. Archive tests cover every hostile fixture. +3. Local HTTP tests cover catalog, download, cancellation, timeout, and changed bytes. +4. Integration tests cover install, update, rollback, conflict, and removal. +5. Packaging smoke tests inspect each platform artifact for protocol registration. +6. Manual release tests cover a closed app, an open app, an offline app, and a missing app. + +Do not add anonymous marketplace telemetry in V1. Existing Cloudflare page analytics can measure site +traffic. Support reports and registry CI failures are enough for the first rollout. + +### `qrate` `site` branch: marketplace and author documentation + +The site is an Astro 7 and Starlight project deployed by Cloudflare Workers Builds. It already +fetches qrate releases at build time. The marketplace must follow the same static-build model. + +Add this site structure: + +```text +src/ + components/ + PluginCard.astro + PluginInstallButton.astro + PluginPermissionList.astro + lib/ + plugins.ts + pages/ + plugins/ + index.astro + [id].astro + submit.astro +public/ + plugin-assets/ +``` + +`src/lib/plugins.ts` fetches `catalog.json` and `catalog.json.sig` at build time. It verifies the +signature with Node crypto before it returns records. It validates the catalog schema version and +fails a production build on a bad signature, bad schema, duplicate ID, or missing required field. +Local development can use a committed fixture through `QRATE_PLUGIN_CATALOG_URL`. + +Do not copy registry records into the site branch. Do not fetch the catalog in the visitor's browser. +Astro must prerender the browse and detail pages so search engines, no-script users, and the MCP/docs +surfaces receive complete content. + +`/plugins` contains: + +- a title and a short explanation of catalog trust; +- client-side search over the prerendered records; +- category and permission filters; +- compatibility, version, publisher, license, and update date on each card; +- a clear revoked or incompatible state; +- links to author source, support, and the submission guide. + +`/plugins/[id]` contains: + +- full listing metadata and screenshots; +- current version and qrate API compatibility; +- requested permissions with plain descriptions; +- release notes, license, publisher, source, and support links; +- an **Open in qrate** button for an active compatible release; +- a copyable direct link and manual instructions as fallbacks; +- the exact Official catalog wording and trust explanation; +- revocation details instead of an install button for a revoked release. + +`PluginInstallButton.astro` creates only this stable link: + +```text +qrate://plugin/install?source=registry&id= +``` + +Do not put an artifact URL, version, hash, or title in the URI. qrate gets current signed data from +the registry and opens a review screen. Add a visible source link beside the button because browsers +do not report custom-protocol failure consistently. + +`/plugins/submit` explains the author flow and links to the template, registry issue form, registry +contribution guide, and package schema. Keep the API reference in the synced qrate user docs. Put +marketplace submission instructions on the site because they span repositories. + +Replace the hard-coded `plugins` array on the home page with catalog-backed featured plugin cards. +Keep the template and **Write your own** cards as separate author calls to action. Change the current +folder-install wording to discovery wording, and retain a manual-install link. + +Add **Plugins** to the main navigation and footer. Add catalog pages to the sitemap and structured +data. Use `SoftwareApplication` or `SoftwareSourceCode` metadata only where the fields are accurate. +Do not add rating markup without real ratings. + +The site already syncs user docs from `main:docs/`. Update those docs in qrate first, then run +`bun run sync-docs` on the site branch. Never hand-edit a generated copy under +`src/content/docs/docs/`. + +The registry publication job triggers the existing Cloudflare deploy hook after it publishes the +signed catalog. This requires no site commit. The Cloudflare build fetches the new catalog and +prerenders the pages. Keep the release-triggered rebuild because release pages still use build-time +GitHub data. + +Add build tests for: + +- a valid signed fixture; +- a bad signature; +- a revoked plugin; +- no plugins; +- duplicate IDs; +- a missing screenshot; +- link generation with reserved characters; +- mobile and keyboard access for search and filters. + +The production build must fail rather than publish stale marketplace data after a signature or +schema error. Cloudflare keeps the last successful deployment online. + +## Cross-repository contracts and order + +Three files are public contracts: + +| Contract | Source of truth | Consumers | +| --- | --- | --- | +| `qrate-plugin.json` schema | Registry `schemas/package.schema.json` | Template, author repositories, qrate | +| Signed `catalog.json` schema | Registry `schemas/catalog.schema.json` | qrate, site | +| `qrate://plugin/install` URI | qrate parser tests and ADR | Site | + +Use versioned fixture bundles to keep consumers in step. Tag the registry contract as `v1` before +qrate or the site consumes a production URL. Pin CI downloads to that tag or a commit, not `main`. + +Implement the work in this dependency order: + +1. Create schemas, fixtures, and unsigned catalog generation in the registry. +2. Add the package manifest and package check to the template. +3. Publish the Islandora pilot artifact. +4. Validate and merge the first registry record. +5. Add catalog verification and local install tests to qrate. +6. Add qrate UI after package operations pass without UI. +7. Add the site against the signed test catalog. +8. Add operating-system deep links after the app review route exists. +9. Publish site install buttons only after packaged deep-link tests pass. +10. Rehearse revocation and key rotation before launch. + +For a contract change, open linked pull requests. Merge the source-of-truth change first. Keep each +consumer compatible with the old and new schema during the transition when possible. + +## Pull-request and repository checklist + +Use small pull requests that leave a testable state. Do not use one cross-repository launch pull +request. + +| Order | Repository or branch | Pull request | Required result | +| --- | --- | --- | --- | +| 1 | `qrate-plugin-registry` | Bootstrap repository and schemas | Source records and fixtures validate without signing | +| 2 | `qrate-plugin-template` | Add manifest and package checks | A tagged fixture produces the expected ZIP and checksum | +| 3 | Islandora plugin | Adopt package contract | A real GitHub Release contains validated assets | +| 4 | `qrate-plugin-registry` | Add Islandora and publication | GitHub Pages serves a signed V1 catalog | +| 5 | `qrate` `main` | Add `plugin-package` foundation | Local tests install, update, reject, and roll back packages | +| 6 | `qrate` `main` | Add discovery and management UI | Official and direct install flows work in a packaged app | +| 7 | `qrate` `main` | Add deep-link and single-instance support | All packaged platforms open the same review route | +| 8 | `qrate` `site` | Add marketplace pages | Static pages build from the signed catalog | +| 9 | `qrate` `site` | Publish install buttons | Buttons appear only after packaged deep-link tests pass | +| 10 | All affected repositories | Documentation and launch pass | Links, policies, fixtures, and runbooks agree | + +No marketplace change is required in `qrate-pi-extension`. The extension does not load, install, or +describe Lua plugins. Add work there only if a later agent feature needs marketplace access. + ## Implementation phases ### Phase 0: contracts and security fixtures From cd29d90266edd6e84c40a2ec4e0f4f9a90fbf55d Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:40:16 -0700 Subject: [PATCH 03/28] docs(plugins): define registry license policy --- docs/dev/plugin-marketplace-plan.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/dev/plugin-marketplace-plan.md b/docs/dev/plugin-marketplace-plan.md index caf5406a..98444f09 100644 --- a/docs/dev/plugin-marketplace-plan.md +++ b/docs/dev/plugin-marketplace-plan.md @@ -422,7 +422,7 @@ The site can show this information in an error page. qrate does not trust `statu The repository policy must define: - minimum source and release requirements; -- allowed licenses and required license files; +- a required package-root license file and the accepted SPDX identifiers; - stable publisher and distribution identity rules; - review rules for new plugins and new versions; - permission-change review; @@ -433,6 +433,12 @@ The repository policy must define: Do not accept a listing through an issue alone. The issue form helps an author prepare a pull request. The pull request remains the reviewed change that publication consumes. +Follow Zed's extension-registry model for licenses. A plugin author keeps their copyright and +chooses an accepted license. V1 accepts Apache-2.0, BSD-2-Clause, BSD-3-Clause, CC-BY-4.0, +GPL-3.0-only, GPL-3.0-or-later, LGPL-3.0-only, LGPL-3.0-or-later, MIT, Unlicense, and Zlib. Registry +CI rejects packages with no root license file, an identifier outside this list, or a mismatch +between the package manifest and license text. The template example uses MIT. + ### `qrate-plugin-template`: author contract and release tooling The template currently has five tracked files. It has no tests, package manifest, release workflow, From 37edc066d53774ecde47751a0ce0ceb39e4a9f0d Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:47:38 -0700 Subject: [PATCH 04/28] feat(plugins): add verified package installer --- Cargo.lock | 16 + Cargo.toml | 1 + crates/plugin-package/Cargo.toml | 17 + crates/plugin-package/src/lib.rs | 609 +++++++++++++++++++++++++++++++ 4 files changed, 643 insertions(+) create mode 100644 crates/plugin-package/Cargo.toml create mode 100644 crates/plugin-package/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 57a19996..1160422c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5874,6 +5874,22 @@ dependencies = [ "settings", ] +[[package]] +name = "plugin-package" +version = "0.4.0-beta.2" +dependencies = [ + "anyhow", + "base64", + "ed25519-dalek", + "reqwest", + "semver", + "serde", + "serde_json", + "sha2", + "tempfile", + "zip 2.4.2", +] + [[package]] name = "png" version = "0.17.16" diff --git a/Cargo.toml b/Cargo.toml index 50408f7b..dd73ed48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/diagnostics", "crates/plugin-api", "crates/plugin-host", + "crates/plugin-package", "crates/preview", "crates/project-wizard", "crates/settings", diff --git a/crates/plugin-package/Cargo.toml b/crates/plugin-package/Cargo.toml new file mode 100644 index 00000000..404c3004 --- /dev/null +++ b/crates/plugin-package/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "plugin-package" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +base64.workspace = true +ed25519-dalek.workspace = true +reqwest = { version = "0.12", features = ["blocking"] } +semver.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +tempfile.workspace = true +zip = { version = "2", default-features = false, features = ["deflate"] } + diff --git a/crates/plugin-package/src/lib.rs b/crates/plugin-package/src/lib.rs new file mode 100644 index 00000000..cd51e2e5 --- /dev/null +++ b/crates/plugin-package/src/lib.rs @@ -0,0 +1,609 @@ +//! Verifies, installs, updates, and removes qrate plugin release packages. + +use std::collections::HashSet; +use std::fs; +use std::io::{Read as _, Write as _}; +use std::path::{Component, Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context as _, Result, bail, ensure}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, Verifier as _, VerifyingKey}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; + +pub const CATALOG_KEY_ID: &str = "qrate-plugin-catalog-1"; +pub const CATALOG_URL: &str = "https://devnull03.github.io/qrate-plugin-registry/catalog.json"; +pub const MAX_CATALOG_BYTES: usize = 5 * 1024 * 1024; +pub const MAX_PACKAGE_BYTES: u64 = 100 * 1024 * 1024; +pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; +pub const MAX_PACKAGE_FILES: usize = 10_000; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PackageManifest { + pub schema: u64, + pub id: String, + pub name: String, + pub version: Version, + pub api_version: u64, + pub entry: String, + pub description: String, + pub homepage: String, + pub license: String, + #[serde(default)] + pub permissions: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Catalog { + pub schema: u64, + pub generated_at: String, + pub source_commit: String, + pub plugins: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CatalogPlugin { + pub id: String, + pub name: String, + pub summary: String, + pub description: String, + #[serde(default)] + pub categories: Vec, + pub license: String, + pub publisher: String, + pub repository: String, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub support: Option, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub screenshots: Vec, + #[serde(default)] + pub featured: bool, + pub current: CatalogRelease, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CatalogRelease { + pub version: Version, + pub api_version: u64, + #[serde(default)] + pub permissions: Vec, + pub published_at: String, + pub release_url: String, + pub artifact_url: String, + pub sha256: String, + pub bytes: u64, + pub status: ReleaseStatus, + #[serde(default)] + pub revocation_reason: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ReleaseStatus { + Active, + Revoked, +} + +#[derive(Debug, Deserialize)] +pub struct CatalogSignature { + pub schema: u64, + pub key_id: String, + pub algorithm: String, + pub sha256: String, + pub signature_base64: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct InstallReceipt { + pub schema: u64, + pub id: String, + pub version: Version, + pub source: InstallSource, + pub repository: String, + pub artifact_url: String, + pub sha256: String, + pub bytes: u64, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum InstallSource { + OfficialCatalog, + DirectGithub, +} + +pub fn verify_catalog(bytes: &[u8], signature_bytes: &[u8], key: &VerifyingKey) -> Result { + ensure!( + bytes.len() <= MAX_CATALOG_BYTES, + "plugin catalog is too large" + ); + let signature: CatalogSignature = + serde_json::from_slice(signature_bytes).context("invalid plugin catalog signature")?; + ensure!( + signature.schema == 1, + "unsupported plugin catalog signature schema" + ); + ensure!( + signature.key_id == CATALOG_KEY_ID, + "unknown plugin catalog signing key" + ); + ensure!( + signature.algorithm == "Ed25519", + "unsupported plugin catalog signature algorithm" + ); + let digest = format!("{:x}", Sha256::digest(bytes)); + ensure!( + digest.eq_ignore_ascii_case(&signature.sha256), + "plugin catalog hash does not match" + ); + let signature = Signature::from_slice( + &STANDARD + .decode(signature.signature_base64) + .context("invalid plugin catalog signature encoding")?, + ) + .context("invalid plugin catalog signature length")?; + key.verify(bytes, &signature) + .context("plugin catalog signature did not verify")?; + + let catalog: Catalog = serde_json::from_slice(bytes).context("invalid plugin catalog")?; + validate_catalog(&catalog)?; + Ok(catalog) +} + +pub fn fetch_catalog(url: &str, key: &VerifyingKey) -> Result { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(20)) + .build()?; + let bytes = bounded_response(client.get(url).send()?, MAX_CATALOG_BYTES as u64) + .context("could not download plugin catalog")?; + let signature = bounded_response(client.get(format!("{url}.sig")).send()?, 64 * 1024) + .context("could not download plugin catalog signature")?; + verify_catalog(&bytes, &signature, key) +} + +fn bounded_response(response: reqwest::blocking::Response, limit: u64) -> Result> { + ensure!( + response.status().is_success(), + "server returned HTTP {}", + response.status() + ); + if let Some(length) = response.content_length() { + ensure!(length <= limit, "download is too large"); + } + let mut bytes = Vec::new(); + response.take(limit + 1).read_to_end(&mut bytes)?; + ensure!(bytes.len() as u64 <= limit, "download is too large"); + Ok(bytes) +} + +pub fn download_package(url: &str, output: &Path) -> Result<(String, u64)> { + let response = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(60)) + .build()? + .get(url) + .send()?; + let bytes = bounded_response(response, MAX_PACKAGE_BYTES)?; + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + fs::write(output, &bytes)?; + Ok((sha256, bytes.len() as u64)) +} + +pub fn install_archive( + archive: &Path, + plugins_root: &Path, + receipts_root: &Path, + source: InstallSource, + expected: Option<(&CatalogPlugin, &CatalogRelease)>, +) -> Result { + let (sha256, bytes) = sha256_file(archive)?; + ensure!(bytes <= MAX_PACKAGE_BYTES, "plugin package is too large"); + if let Some((_, release)) = expected { + ensure!( + release.status == ReleaseStatus::Active, + "plugin release is revoked" + ); + ensure!( + bytes == release.bytes, + "plugin package size does not match catalog" + ); + ensure!( + sha256.eq_ignore_ascii_case(&release.sha256), + "plugin package hash does not match catalog" + ); + } + + fs::create_dir_all(plugins_root)?; + fs::create_dir_all(receipts_root)?; + let staging = tempfile::Builder::new() + .prefix("qrate-plugin-") + .tempdir_in(plugins_root)?; + extract_package(archive, staging.path())?; + let manifest: PackageManifest = serde_json::from_slice( + &fs::read(staging.path().join("qrate-plugin.json")) + .context("plugin package has no qrate-plugin.json")?, + ) + .context("invalid qrate-plugin.json")?; + validate_manifest(&manifest, staging.path())?; + + if let Some((plugin, release)) = expected { + ensure!( + manifest.id == plugin.id, + "package ID does not match catalog" + ); + ensure!( + manifest.name == plugin.name, + "package name does not match catalog" + ); + ensure!( + manifest.version == release.version, + "package version does not match catalog" + ); + ensure!( + manifest.api_version == release.api_version, + "package API version does not match catalog" + ); + ensure!( + manifest.license == plugin.license, + "package license does not match catalog" + ); + ensure!( + manifest.permissions == release.permissions, + "package permissions do not match catalog" + ); + } + + let receipt_path = receipt_path(receipts_root, &manifest.id)?; + let target = plugins_root.join(&manifest.id); + ensure!( + !target.exists() || receipt_path.exists(), + "an unmanaged plugin already uses this package ID" + ); + let rollback = plugins_root.join(format!(".{}.rollback", manifest.id)); + if rollback.exists() { + fs::remove_dir_all(&rollback)?; + } + if target.exists() { + fs::rename(&target, &rollback).context("could not retain the installed plugin")?; + } + + let staged = staging.keep(); + if let Err(error) = fs::rename(&staged, &target) { + if rollback.exists() { + let _ = fs::rename(&rollback, &target); + } + return Err(error).context("could not activate the plugin package"); + } + + let receipt = InstallReceipt { + schema: 1, + id: manifest.id, + version: manifest.version, + source, + repository: expected.map_or_else( + || manifest.homepage.clone(), + |(plugin, _)| plugin.repository.clone(), + ), + artifact_url: expected + .map_or_else(String::new, |(_, release)| release.artifact_url.clone()), + sha256, + bytes, + }; + if let Err(error) = write_json_atomic(&receipt_path, &receipt) { + let _ = fs::remove_dir_all(&target); + if rollback.exists() { + let _ = fs::rename(&rollback, &target); + } + return Err(error).context("could not record the plugin installation"); + } + if rollback.exists() { + fs::remove_dir_all(rollback)?; + } + Ok(receipt) +} + +pub fn remove_managed(id: &str, plugins_root: &Path, receipts_root: &Path) -> Result<()> { + let receipt = receipt_path(receipts_root, id)?; + ensure!(receipt.exists(), "qrate does not manage this plugin"); + let target = plugins_root.join(id); + if target.exists() { + fs::remove_dir_all(target)?; + } + fs::remove_file(receipt)?; + Ok(()) +} + +pub fn receipts_dir(data_dir: &Path) -> PathBuf { + data_dir.join("plugin-installs") +} + +pub fn read_receipt(receipts_root: &Path, id: &str) -> Result> { + let path = receipt_path(receipts_root, id)?; + match fs::read(path) { + Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn receipt_path(receipts_root: &Path, id: &str) -> Result { + validate_id(id)?; + Ok(receipts_root.join(format!("{id}.json"))) +} + +fn validate_catalog(catalog: &Catalog) -> Result<()> { + ensure!(catalog.schema == 1, "unsupported plugin catalog schema"); + let mut ids = HashSet::new(); + for plugin in &catalog.plugins { + validate_id(&plugin.id)?; + ensure!(ids.insert(&plugin.id), "duplicate plugin ID {}", plugin.id); + validate_sha256(&plugin.current.sha256)?; + ensure!( + plugin + .current + .permissions + .iter() + .collect::>() + .len() + == plugin.current.permissions.len(), + "duplicate permission in {}", + plugin.id + ); + } + Ok(()) +} + +fn validate_manifest(manifest: &PackageManifest, root: &Path) -> Result<()> { + ensure!(manifest.schema == 1, "unsupported package manifest schema"); + validate_id(&manifest.id)?; + ensure!(!manifest.name.trim().is_empty(), "package name is empty"); + ensure!( + manifest.api_version > 0, + "package API version must be positive" + ); + let mut entry = Path::new(&manifest.entry).components(); + ensure!( + matches!(entry.next(), Some(Component::Normal(_))) && entry.next().is_none(), + "package entry must be a root file" + ); + ensure!( + root.join(&manifest.entry).is_file(), + "package entry does not exist" + ); + ensure!( + manifest + .permissions + .iter() + .all(|permission| permission == "net"), + "package requests an unknown permission" + ); + ensure!( + manifest.permissions.iter().collect::>().len() == manifest.permissions.len(), + "package repeats a permission" + ); + ensure!( + fs::read_dir(root)?.flatten().any(|entry| { + entry.file_type().is_ok_and(|kind| kind.is_file()) + && entry + .file_name() + .to_string_lossy() + .to_ascii_uppercase() + .starts_with("LICENSE") + }), + "plugin package has no root license file" + ); + Ok(()) +} + +fn validate_id(id: &str) -> Result<()> { + ensure!( + !id.is_empty() + && id.bytes().all(|byte| byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || b"._-".contains(&byte)) + && id.contains('.') + && !id.starts_with(['.', '-', '_']) + && !id.ends_with(['.', '-', '_']) + && !id.contains(".."), + "invalid plugin ID" + ); + Ok(()) +} + +fn validate_sha256(value: &str) -> Result<()> { + ensure!( + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), + "invalid SHA-256" + ); + Ok(()) +} + +fn extract_package(archive: &Path, output: &Path) -> Result<()> { + let file = fs::File::open(archive)?; + let mut archive = zip::ZipArchive::new(file).context("invalid plugin ZIP")?; + ensure!( + archive.len() <= MAX_PACKAGE_FILES, + "plugin package has too many files" + ); + let mut names = HashSet::new(); + let mut expanded = 0_u64; + + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + let relative = entry.enclosed_name().context("unsafe path in plugin ZIP")?; + ensure!(!relative.as_os_str().is_empty(), "empty path in plugin ZIP"); + ensure!( + names.insert(relative.clone()), + "duplicate path in plugin ZIP" + ); + if entry + .unix_mode() + .is_some_and(|mode| mode & 0o170000 == 0o120000) + { + bail!("plugin ZIP contains a symbolic link"); + } + expanded = expanded + .checked_add(entry.size()) + .context("plugin package expansion overflow")?; + ensure!( + expanded <= MAX_EXPANDED_BYTES, + "plugin package expands too large" + ); + + let path = output.join(relative); + if entry.is_dir() { + fs::create_dir_all(path)?; + } else { + fs::create_dir_all(path.parent().context("plugin ZIP entry has no parent")?)?; + let mut file = fs::File::create(path)?; + std::io::copy(&mut entry, &mut file)?; + file.flush()?; + } + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result<(String, u64)> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let bytes = std::io::copy(&mut file, &mut hasher)?; + Ok((format!("{:x}", hasher.finalize()), bytes)) +} + +fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { + fs::create_dir_all(path.parent().context("receipt has no parent")?)?; + let mut temp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; + serde_json::to_writer_pretty(&mut temp, value)?; + temp.write_all(b"\n")?; + temp.as_file_mut().sync_all()?; + temp.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io::Write as _; + + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use ed25519_dalek::{Signer as _, SigningKey}; + use serde_json::json; + use sha2::{Digest as _, Sha256}; + use tempfile::tempdir; + use zip::{ZipWriter, write::SimpleFileOptions}; + + use super::{ + CATALOG_KEY_ID, InstallSource, install_archive, read_receipt, remove_managed, + verify_catalog, + }; + + fn package(path: &std::path::Path, id: &str, extra: Option<(&str, &[u8])>) { + let file = fs::File::create(path).unwrap(); + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default(); + for (name, body) in [ + ( + "qrate-plugin.json", + serde_json::to_vec(&json!({ + "schema": 1, + "id": id, + "name": "Example", + "version": "1.0.0", + "api_version": 1, + "entry": "init.lua", + "description": "Example", + "homepage": "https://example.org/plugin", + "license": "MIT", + "permissions": [] + })) + .unwrap(), + ), + ("init.lua", b"return {}".to_vec()), + ("LICENSE", b"MIT".to_vec()), + ] { + zip.start_file(name, options).unwrap(); + zip.write_all(&body).unwrap(); + } + if let Some((name, body)) = extra { + zip.start_file(name, options).unwrap(); + zip.write_all(body).unwrap(); + } + zip.finish().unwrap(); + } + + #[test] + fn signed_catalog_verifies_and_tampering_fails() { + let key = SigningKey::from_bytes(&[7; 32]); + let bytes = br#"{"schema":1,"generated_at":"now","source_commit":"abc","plugins":[]}"#; + let signature = json!({ + "schema": 1, + "key_id": CATALOG_KEY_ID, + "algorithm": "Ed25519", + "sha256": format!("{:x}", Sha256::digest(bytes)), + "signature_base64": STANDARD.encode(key.sign(bytes).to_bytes()) + }); + verify_catalog( + bytes, + &serde_json::to_vec(&signature).unwrap(), + &key.verifying_key(), + ) + .unwrap(); + assert!( + verify_catalog( + b"{}", + &serde_json::to_vec(&signature).unwrap(), + &key.verifying_key() + ) + .is_err() + ); + } + + #[test] + fn installs_and_removes_a_managed_package() { + let root = tempdir().unwrap(); + let archive = root.path().join("plugin.zip"); + package(&archive, "org.example.plugin", None); + let plugins = root.path().join("plugins"); + let receipts = root.path().join("receipts"); + let receipt = install_archive( + &archive, + &plugins, + &receipts, + InstallSource::DirectGithub, + None, + ) + .unwrap(); + assert!(plugins.join("org.example.plugin/init.lua").is_file()); + assert_eq!( + read_receipt(&receipts, "org.example.plugin") + .unwrap() + .unwrap() + .version, + receipt.version + ); + remove_managed("org.example.plugin", &plugins, &receipts).unwrap(); + assert!(!plugins.join("org.example.plugin").exists()); + } + + #[test] + fn rejects_path_traversal() { + let root = tempdir().unwrap(); + let archive = root.path().join("plugin.zip"); + package(&archive, "org.example.plugin", Some(("../outside", b"bad"))); + assert!( + install_archive( + &archive, + &root.path().join("plugins"), + &root.path().join("receipts"), + InstallSource::DirectGithub, + None + ) + .is_err() + ); + } +} From 888b1436eb2e34f76a877ce146dd79fe95f44754 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:26:16 -0700 Subject: [PATCH 05/28] feat(plugins): add marketplace installation UI --- Cargo.lock | 2 + crates/app/Cargo.toml | 2 + crates/app/src/app_menus.rs | 7 + crates/app/src/app_settings/mod.rs | 1 + crates/app/src/main.rs | 10 + crates/app/src/plugin_marketplace.rs | 457 +++++++++++++++++++++ crates/app/src/title_items/launcher_bar.rs | 14 +- crates/plugin-package/Cargo.toml | 2 +- crates/plugin-package/src/lib.rs | 134 +++++- 9 files changed, 624 insertions(+), 5 deletions(-) create mode 100644 crates/app/src/plugin_marketplace.rs diff --git a/Cargo.lock b/Cargo.lock index 1160422c..6632c4cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,6 +272,7 @@ dependencies = [ "os_info", "plugin-api", "plugin-host", + "plugin-package", "preview", "project-wizard", "schemars", @@ -283,6 +284,7 @@ dependencies = [ "spellcheck", "sysinfo 0.37.2", "table", + "tempfile", "updater", "window-wrapper", "winresource", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 89635048..7da2b530 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -19,6 +19,7 @@ semver.workspace = true serde.workspace = true serde_json.workspace = true schemars.workspace = true +tempfile.workspace = true # The agent bridge's request/response types; the transport lives here because only the # composition root links both the contract and the live table. ai = { path = "../ai" } @@ -46,6 +47,7 @@ updater = { path = "../updater", features = ["client"] } plugin-api = { path = "../plugin-api" } # Plugins are loaded here and nowhere else — see `plugin_host::reload`. plugin-host = { path = "../plugin-host" } +plugin-package = { path = "../plugin-package" } workspace = { path = "../workspace" } [build-dependencies] diff --git a/crates/app/src/app_menus.rs b/crates/app/src/app_menus.rs index 0ad94249..8fa55722 100644 --- a/crates/app/src/app_menus.rs +++ b/crates/app/src/app_menus.rs @@ -20,6 +20,9 @@ actions!( /// The same window as `OpenSettings`, opened on the Columns page — the Data menu promises /// a column surface, and landing on whatever page was last used does not keep that promise. OpenColumnSettings, + DiscoverPlugins, + InstallPluginFromLink, + ManagePlugins, OpenPluginsFolder, ReloadPlugins, Quit, @@ -182,6 +185,10 @@ fn app_menus(cx: &gpui::App) -> Vec { name: "Extensions".into(), disabled: false, items: vec![ + MenuItem::action("Discover Plugins…", DiscoverPlugins), + MenuItem::action("Install Plugin from Link…", InstallPluginFromLink), + MenuItem::action("Manage Plugins…", ManagePlugins), + MenuItem::Separator, MenuItem::action("Plugins Folder", OpenPluginsFolder), MenuItem::action("Reload Plugins", ReloadPlugins), MenuItem::Separator, diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index eb05ba74..f623efbf 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -29,6 +29,7 @@ use settings::{Setting, columns, project::CurrentProject}; /// Where the Columns page sits in [`build_pages`], for the Data menu's "Column Settings…". An index /// rather than a title because `SettingPage` does not hand its title back. pub const COLUMNS_PAGE: usize = 2; +pub const PLUGINS_PAGE: usize = 7; pub fn build_pages(cx: &App) -> Vec { let mut pages = vec![ diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index affddf33..1d883313 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -9,6 +9,7 @@ mod assets; mod export; mod google; mod logging; +mod plugin_marketplace; mod status_items; mod theming; mod title_items; @@ -511,6 +512,15 @@ fn main() { table::revalidate_now(cx); }); cx.on_action(|_: &OpenPluginsFolder, _| plugin_host::open_plugins_folder()); + cx.on_action(|_: &app_menus::DiscoverPlugins, cx| { + plugin_marketplace::open_marketplace_window(false, cx) + }); + cx.on_action(|_: &app_menus::InstallPluginFromLink, cx| { + plugin_marketplace::open_marketplace_window(true, cx) + }); + cx.on_action(|_: &app_menus::ManagePlugins, cx| { + open_settings_window(Some(app_settings::PLUGINS_PAGE), cx) + }); cx.on_action(|_: &CopyDebugInfo, cx| { cx.write_to_clipboard(ClipboardItem::new_string(logging::debug_info(cx, 200))); diff --git a/crates/app/src/plugin_marketplace.rs b/crates/app/src/plugin_marketplace.rs new file mode 100644 index 00000000..401f1da1 --- /dev/null +++ b/crates/app/src/plugin_marketplace.rs @@ -0,0 +1,457 @@ +//! Signed plugin discovery and direct GitHub release installation. + +use std::path::PathBuf; +use std::sync::Arc; + +use gpui::prelude::FluentBuilder as _; +use gpui::*; +use gpui_component::button::Button; +use gpui_component::input::{Input, InputState}; +use gpui_component::scroll::ScrollableElement as _; +use gpui_component::{ + ActiveTheme as _, Disableable as _, Root, Sizable as _, StyledExt as _, TitleBar, h_flex, + label::Label, v_flex, +}; +use plugin_package::{ + CatalogPlugin, DirectRelease, InstallSource, PackageInspection, ReleaseStatus, +}; +use window_wrapper::WindowRegistry; + +const MARKETPLACE_WINDOW_KIND: &str = "plugin-marketplace"; +const SITE_URL: &str = "https://qrate.dvnl.work/plugins"; + +enum CatalogState { + Loading, + Ready(Vec), + Error(Arc), +} + +enum DirectState { + Idle, + Loading, + Review { + release: DirectRelease, + inspection: PackageInspection, + archive: PathBuf, + }, + Error(Arc), +} + +pub struct MarketplaceWindow { + catalog: CatalogState, + direct: DirectState, + input: Entity, + status: Option>, +} + +impl MarketplaceWindow { + fn new(direct: bool, window: &mut Window, cx: &mut Context) -> Self { + window.set_window_title("qrate plugins"); + let input = + cx.new(|cx| InputState::new(window, cx).placeholder("https://github.com/owner/plugin")); + let mut this = Self { + catalog: CatalogState::Loading, + direct: DirectState::Idle, + input, + status: None, + }; + this.refresh(cx); + if direct { + this.input.focus_handle(cx).focus(window, cx); + } + this + } + + fn refresh(&mut self, cx: &mut Context) { + self.catalog = CatalogState::Loading; + let key = option_env!("QRATE_PLUGIN_CATALOG_PUBLIC_KEY") + .map(str::to_owned) + .or_else(|| std::env::var("QRATE_PLUGIN_CATALOG_PUBLIC_KEY").ok()); + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let key = key.ok_or_else(|| { + anyhow::anyhow!("this build has no plugin catalog public key") + })?; + let key = plugin_package::public_key(&key)?; + plugin_package::fetch_catalog(plugin_package::CATALOG_URL, &key) + }) + .await; + this.update(cx, |this, cx| { + this.catalog = match result { + Ok(catalog) => CatalogState::Ready(catalog.plugins), + Err(error) => CatalogState::Error(format!("{error:#}").into()), + }; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn resolve_direct(&mut self, cx: &mut Context) { + let source = self.input.read(cx).value().to_string(); + if source.trim().is_empty() { + self.direct = + DirectState::Error("Paste a public GitHub repository or release URL".into()); + cx.notify(); + return; + } + self.direct = DirectState::Loading; + cx.notify(); + cx.spawn(async move |this, cx| { + let result = cx + .background_spawn(async move { + let release = plugin_package::resolve_github_release(&source)?; + let temp = tempfile::Builder::new() + .prefix("qrate-plugin-download-") + .suffix(".zip") + .tempfile()?; + plugin_package::download_package(&release.artifact_url, temp.path())?; + let inspection = plugin_package::inspect_archive(temp.path())?; + let archive = temp.into_temp_path().keep()?; + anyhow::Ok((release, inspection, archive)) + }) + .await; + this.update(cx, |this, cx| { + this.direct = match result { + Ok((release, inspection, archive)) => DirectState::Review { + release, + inspection, + archive, + }, + Err(error) => DirectState::Error(format!("{error:#}").into()), + }; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn install_official( + &mut self, + plugin: CatalogPlugin, + window: &mut Window, + cx: &mut Context, + ) { + if plugin.current.status == ReleaseStatus::Revoked { + self.status = Some("This release is revoked and cannot be installed".into()); + cx.notify(); + return; + } + let permissions = if plugin.current.permissions.is_empty() { + "No optional permissions".to_string() + } else { + format!("Requests: {}", plugin.current.permissions.join(", ")) + }; + let answer = window.prompt( + PromptLevel::Warning, + &format!("Install {} {}?", plugin.name, plugin.current.version), + Some(&format!( + "Official catalog · {} · {permissions}. The plugin will be disabled after installation.", + plugin.publisher + )), + &["Install", "Cancel"], + cx, + ); + cx.spawn(async move |this, cx| { + if answer.await.unwrap_or(1) != 0 { + return; + } + this.update(cx, |this, cx| { + this.status = Some("Downloading and checking package…".into()); + cx.notify(); + }) + .ok(); + let result = cx + .background_spawn(async move { + let temp = tempfile::Builder::new() + .prefix("qrate-plugin-download-") + .suffix(".zip") + .tempfile()?; + plugin_package::download_package(&plugin.current.artifact_url, temp.path())?; + let plugins = plugin_host::plugins_dir() + .ok_or_else(|| anyhow::anyhow!("qrate application data is unavailable"))?; + let data = plugins + .parent() + .ok_or_else(|| anyhow::anyhow!("plugin folder has no parent"))?; + let receipts = plugin_package::receipts_dir(data); + plugin_package::install_archive( + temp.path(), + &plugins, + &receipts, + InstallSource::OfficialCatalog, + Some((&plugin, &plugin.current)), + ) + }) + .await; + this.update(cx, |this, cx| { + match result { + Ok(receipt) => { + settings::plugins::set_enabled(&receipt.id, false, cx); + plugin_host::reload(cx); + this.status = Some( + format!( + "{} {} installed disabled. Enable it in Settings.", + receipt.id, receipt.version + ) + .into(), + ); + } + Err(error) => { + this.status = Some(format!("Installation failed: {error:#}").into()) + } + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } + + fn install_direct(&mut self, window: &mut Window, cx: &mut Context) { + let DirectState::Review { + release, + inspection, + archive, + } = &self.direct + else { + return; + }; + let (release, inspection, archive) = (release.clone(), inspection.clone(), archive.clone()); + let permissions = if inspection.manifest.permissions.is_empty() { + "No optional permissions".to_string() + } else { + format!("Requests: {}", inspection.manifest.permissions.join(", ")) + }; + let answer = window.prompt( + PromptLevel::Warning, + &format!( + "Install unlisted plugin {} {}?", + inspection.manifest.name, inspection.manifest.version + ), + Some(&format!( + "{} · SHA-256 {} · {permissions}. qrate has not reviewed this source.", + release.repository, inspection.sha256 + )), + &["Install", "Cancel"], + cx, + ); + cx.spawn(async move |this, cx| { + if answer.await.unwrap_or(1) != 0 { + return; + } + let result = cx + .background_spawn(async move { + let plugins = plugin_host::plugins_dir() + .ok_or_else(|| anyhow::anyhow!("qrate application data is unavailable"))?; + let data = plugins + .parent() + .ok_or_else(|| anyhow::anyhow!("plugin folder has no parent"))?; + let receipts = plugin_package::receipts_dir(data); + let result = plugin_package::install_archive( + &archive, + &plugins, + &receipts, + InstallSource::DirectGithub, + None, + ); + let _ = std::fs::remove_file(archive); + result + }) + .await; + this.update(cx, |this, cx| { + match result { + Ok(receipt) => { + settings::plugins::set_enabled(&receipt.id, false, cx); + plugin_host::reload(cx); + this.status = Some( + format!( + "{} {} installed disabled. Enable it in Settings.", + receipt.id, receipt.version + ) + .into(), + ); + this.direct = DirectState::Idle; + } + Err(error) => { + this.status = Some(format!("Installation failed: {error:#}").into()) + } + } + cx.notify(); + }) + .ok(); + }) + .detach(); + } +} + +impl Render for MarketplaceWindow { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let catalog = match &self.catalog { + CatalogState::Loading => Label::new("Loading the signed catalog…") + .text_sm() + .into_any_element(), + CatalogState::Error(error) => h_flex() + .gap_2() + .child( + Label::new(format!("Catalog unavailable: {error}")) + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + .child( + Button::new("catalog-site") + .label("Browse website") + .small() + .on_click(|_, _, cx| cx.open_url(SITE_URL)), + ) + .into_any_element(), + CatalogState::Ready(plugins) if plugins.is_empty() => { + Label::new("The official catalog has no plugins yet.") + .text_sm() + .into_any_element() + } + CatalogState::Ready(plugins) => v_flex() + .gap_2() + .children(plugins.iter().cloned().map(|plugin| { + let button_plugin = plugin.clone(); + h_flex() + .justify_between() + .gap_3() + .p_3() + .border_1() + .border_color(cx.theme().border) + .child( + v_flex() + .child(Label::new(plugin.name).font_semibold()) + .child( + Label::new(format!( + "{} · {} · API {}", + plugin.summary, + plugin.current.version, + plugin.current.api_version + )) + .text_sm() + .text_color(cx.theme().muted_foreground), + ), + ) + .child( + Button::new(format!("install-{}", plugin.id)) + .label(if plugin.current.status == ReleaseStatus::Revoked { + "Revoked" + } else { + "Install" + }) + .small() + .disabled(plugin.current.status == ReleaseStatus::Revoked) + .on_click(cx.listener(move |this, _, window, cx| { + this.install_official(button_plugin.clone(), window, cx); + })), + ) + })) + .into_any_element(), + }; + + let direct = match &self.direct { + DirectState::Idle => Label::new( + "Direct installs are unlisted. qrate downloads the package before it shows the final review.", + ) + .text_sm() + .text_color(cx.theme().muted_foreground) + .into_any_element(), + DirectState::Loading => Label::new("Resolving and checking release…") + .text_sm() + .into_any_element(), + DirectState::Error(error) => Label::new(format!("Could not review package: {error}")) + .text_sm() + .text_color(cx.theme().danger_foreground) + .into_any_element(), + DirectState::Review { + release, + inspection, + .. + } => h_flex() + .justify_between() + .gap_3() + .child( + Label::new(format!( + "{} {} · {} bytes · SHA-256 {} · {}", + inspection.manifest.name, + inspection.manifest.version, + inspection.bytes, + inspection.sha256, + release.repository + )) + .text_sm(), + ) + .child( + Button::new("install-direct") + .label("Install unlisted plugin") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.install_direct(window, cx); + })), + ) + .into_any_element(), + }; + + v_flex() + .size_full() + .child( + TitleBar::new() + .text_xs() + .text_color(cx.theme().foreground) + .child(Label::new("qrate plugins").font_semibold()), + ) + .child( + v_flex() + .flex_1() + .overflow_y_scrollbar() + .p_4() + .gap_4() + .child(Label::new("Official catalog").text_lg().font_semibold()) + .child(catalog) + .child(Label::new("Install from GitHub").text_lg().font_semibold()) + .child( + h_flex() + .gap_2() + .child(div().flex_1().child(Input::new(&self.input))) + .child( + Button::new("review-direct") + .label("Review package") + .on_click(cx.listener(|this, _, _, cx| { + this.resolve_direct(cx); + })), + ), + ) + .child(direct) + .when_some(self.status.clone(), |view, status| { + view.child( + Label::new(status) + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + }), + ) + } +} + +pub fn open_marketplace_window(direct: bool, cx: &mut gpui::App) { + if WindowRegistry::focus_or_clear(MARKETPLACE_WINDOW_KIND, cx).is_some() { + return; + } + let win_size = size(px(760.0), px(620.0)); + let options = WindowOptions { + titlebar: Some(TitleBar::title_bar_options()), + window_bounds: Some(WindowBounds::Windowed(Bounds::centered(None, win_size, cx))), + window_min_size: Some(size(px(560.0), px(420.0))), + ..Default::default() + }; + if let Ok(handle) = cx.open_window(options, |window, cx| { + let view = cx.new(|cx| MarketplaceWindow::new(direct, window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }) { + WindowRegistry::register(MARKETPLACE_WINDOW_KIND, handle.into(), cx); + } +} diff --git a/crates/app/src/title_items/launcher_bar.rs b/crates/app/src/title_items/launcher_bar.rs index 028bebb5..0e04b59c 100644 --- a/crates/app/src/title_items/launcher_bar.rs +++ b/crates/app/src/title_items/launcher_bar.rs @@ -14,8 +14,9 @@ use window_wrapper::OpenBrowser; use crate::actions::NewProject; use crate::app_menus::{ - CopyDebugInfo, OpenAbout, OpenLogsFolder, OpenPluginsFolder, OpenSettings, REPO_URL, - ReloadPlugins, ReportBug, ReportUxIssue, RequestFeature, + CopyDebugInfo, DiscoverPlugins, InstallPluginFromLink, ManagePlugins, OpenAbout, + OpenLogsFolder, OpenPluginsFolder, OpenSettings, REPO_URL, ReloadPlugins, ReportBug, + ReportUxIssue, RequestFeature, }; use crate::title_items::update_notice::UpdateNotice; @@ -59,7 +60,14 @@ impl Render for LauncherBar { .menu("Settings…", Box::new(OpenSettings)) .separator() .submenu("Extensions", window, cx, |menu, _, _| { - menu.menu("Plugins Folder", Box::new(OpenPluginsFolder)) + menu.menu("Discover Plugins…", Box::new(DiscoverPlugins)) + .menu( + "Install Plugin from Link…", + Box::new(InstallPluginFromLink), + ) + .menu("Manage Plugins…", Box::new(ManagePlugins)) + .separator() + .menu("Plugins Folder", Box::new(OpenPluginsFolder)) .menu("Reload Plugins", Box::new(ReloadPlugins)) }) .submenu("Help", window, cx, |menu, window, cx| { diff --git a/crates/plugin-package/Cargo.toml b/crates/plugin-package/Cargo.toml index 404c3004..9038870a 100644 --- a/crates/plugin-package/Cargo.toml +++ b/crates/plugin-package/Cargo.toml @@ -7,7 +7,7 @@ edition.workspace = true anyhow.workspace = true base64.workspace = true ed25519-dalek.workspace = true -reqwest = { version = "0.12", features = ["blocking"] } +reqwest = { version = "0.12", features = ["blocking", "json"] } semver.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/plugin-package/src/lib.rs b/crates/plugin-package/src/lib.rs index cd51e2e5..28c72408 100644 --- a/crates/plugin-package/src/lib.rs +++ b/crates/plugin-package/src/lib.rs @@ -7,7 +7,10 @@ use std::path::{Component, Path, PathBuf}; use std::time::Duration; use anyhow::{Context as _, Result, bail, ensure}; -use base64::{Engine as _, engine::general_purpose::STANDARD}; +use base64::{ + Engine as _, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; use ed25519_dalek::{Signature, Verifier as _, VerifyingKey}; use semver::Version; use serde::{Deserialize, Serialize}; @@ -111,6 +114,23 @@ pub struct InstallReceipt { pub bytes: u64, } +#[derive(Clone, Debug)] +pub struct PackageInspection { + pub manifest: PackageManifest, + pub sha256: String, + pub bytes: u64, +} + +#[derive(Clone, Debug)] +pub struct DirectRelease { + pub repository: String, + pub version: String, + pub release_url: String, + pub artifact_url: String, + pub artifact_name: String, + pub bytes: u64, +} + #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum InstallSource { @@ -118,6 +138,15 @@ pub enum InstallSource { DirectGithub, } +pub fn public_key(base64url: &str) -> Result { + let bytes: [u8; 32] = URL_SAFE_NO_PAD + .decode(base64url) + .context("invalid plugin catalog public key encoding")? + .try_into() + .map_err(|_| anyhow::anyhow!("invalid plugin catalog public key length"))?; + VerifyingKey::from_bytes(&bytes).context("invalid plugin catalog public key") +} + pub fn verify_catalog(bytes: &[u8], signature_bytes: &[u8], key: &VerifyingKey) -> Result { ensure!( bytes.len() <= MAX_CATALOG_BYTES, @@ -194,6 +223,109 @@ pub fn download_package(url: &str, output: &Path) -> Result<(String, u64)> { Ok((sha256, bytes.len() as u64)) } +pub fn resolve_github_release(source: &str) -> Result { + let url = reqwest::Url::parse(source).context("invalid GitHub URL")?; + ensure!( + url.scheme() == "https" && url.host_str() == Some("github.com"), + "only public HTTPS GitHub repositories are supported" + ); + ensure!( + url.username().is_empty() && url.password().is_none(), + "GitHub URL must not contain credentials" + ); + let parts: Vec<_> = url + .path_segments() + .into_iter() + .flatten() + .filter(|part| !part.is_empty()) + .collect(); + ensure!( + parts.len() >= 2, + "GitHub URL must name an owner and repository" + ); + let owner = parts[0]; + let repository = parts[1].trim_end_matches(".git"); + ensure!( + owner + .bytes() + .chain(repository.bytes()) + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)), + "GitHub owner or repository is invalid" + ); + + let endpoint = if parts.get(2..4) == Some(&["releases", "tag"]) && parts.len() == 5 { + format!( + "https://api.github.com/repos/{owner}/{repository}/releases/tags/{}", + parts[4] + ) + } else { + ensure!(parts.len() == 2, "unsupported GitHub release URL"); + format!("https://api.github.com/repos/{owner}/{repository}/releases/latest") + }; + #[derive(Deserialize)] + struct Asset { + name: String, + browser_download_url: String, + size: u64, + } + #[derive(Deserialize)] + struct Release { + tag_name: String, + html_url: String, + assets: Vec, + } + let release: Release = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(20)) + .user_agent("qrate-plugin-installer") + .build()? + .get(endpoint) + .send()? + .error_for_status() + .context("GitHub could not resolve that release")? + .json()?; + let mut packages = release + .assets + .into_iter() + .filter(|asset| asset.name.to_ascii_lowercase().ends_with(".zip")); + let asset = packages + .next() + .context("GitHub release has no plugin ZIP asset")?; + ensure!( + packages.next().is_none(), + "GitHub release has more than one ZIP asset" + ); + ensure!( + asset.size <= MAX_PACKAGE_BYTES, + "plugin package is too large" + ); + Ok(DirectRelease { + repository: format!("https://github.com/{owner}/{repository}"), + version: release.tag_name, + release_url: release.html_url, + artifact_url: asset.browser_download_url, + artifact_name: asset.name, + bytes: asset.size, + }) +} + +pub fn inspect_archive(archive: &Path) -> Result { + let (sha256, bytes) = sha256_file(archive)?; + ensure!(bytes <= MAX_PACKAGE_BYTES, "plugin package is too large"); + let staging = tempfile::tempdir()?; + extract_package(archive, staging.path())?; + let manifest: PackageManifest = serde_json::from_slice( + &fs::read(staging.path().join("qrate-plugin.json")) + .context("plugin package has no qrate-plugin.json")?, + ) + .context("invalid qrate-plugin.json")?; + validate_manifest(&manifest, staging.path())?; + Ok(PackageInspection { + manifest, + sha256, + bytes, + }) +} + pub fn install_archive( archive: &Path, plugins_root: &Path, From 05a80d7f935ae2ce3dace791f1d415e790cf2cc8 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:54:19 -0700 Subject: [PATCH 06/28] feat(plugins): manage packages and install links --- crates/app/src/app_settings/mod.rs | 96 ++++++++- crates/app/src/main.rs | 35 ++- crates/app/src/plugin_marketplace.rs | 181 +++++++++++----- crates/plugin-host/src/lib.rs | 12 +- crates/plugin-package/src/lib.rs | 307 ++++++++++++++++++++++++--- 5 files changed, 537 insertions(+), 94 deletions(-) diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs index f623efbf..45ae6472 100644 --- a/crates/app/src/app_settings/mod.rs +++ b/crates/app/src/app_settings/mod.rs @@ -7,7 +7,7 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, AppContext as _, Axis, Entity, Global, IntoElement, ParentElement as _, - SharedString, Styled as _, Subscription, Window, div, px, + PromptLevel, SharedString, Styled as _, Subscription, Window, div, px, }; use gpui_component::{ ActiveTheme as _, Disableable as _, Icon, IconName, IndexPath, Sizable as _, @@ -149,6 +149,12 @@ fn plugins_page(cx: &App) -> SettingPage { for plugin in listing { let id = plugin.id.clone(); + let receipt = plugin_host::plugins_dir().and_then(|plugins| { + let data = plugins.parent()?; + plugin_package::read_receipt(&plugin_package::receipts_dir(data), &id) + .ok() + .flatten() + }); let mut group = SettingGroup::new().title(plugin.id.clone()).item({ let switched = id.clone(); let item = SettingItem::new( @@ -171,7 +177,24 @@ fn plugins_page(cx: &App) -> SettingPage { // a settings row that text of that length fits. match (&plugin.load_error, &plugin.description) { (Some(err), _) => item.description(SharedString::from(format!("✗ {err}"))), - (None, Some(description)) => item.description(description.clone()), + (None, Some(description)) => { + let description = receipt.as_ref().map_or_else( + || description.to_string(), + |receipt| { + format!( + "{description} Installed {} from {} (SHA-256 {}).", + receipt.version, + match receipt.source { + plugin_package::InstallSource::OfficialCatalog => + "the official catalog", + plugin_package::InstallSource::DirectGithub => "GitHub", + }, + receipt.sha256 + ) + }, + ); + item.description(SharedString::from(description)) + } (None, None) => item, } }); @@ -207,6 +230,75 @@ fn plugins_page(cx: &App) -> SettingPage { ); } + if let Some(receipt) = receipt { + let remove_id = receipt.id.clone(); + group = group.item( + SettingItem::new( + "Managed installation", + SettingField::element(move |_opts: &_, _window: &mut Window, _cx: &mut App| { + let id = remove_id.clone(); + Button::new(format!("remove-managed-{id}")) + .small() + .label("Remove package…") + .on_click(move |_, window, cx| { + let answer = window.prompt( + PromptLevel::Warning, + &format!("Remove {id}?"), + Some( + "qrate will delete this managed package. Plugin settings are retained.", + ), + &["Remove", "Cancel"], + cx, + ); + let id = id.clone(); + cx.spawn(async move |cx| { + if answer.await.unwrap_or(1) != 0 { + return; + } + cx.update(|cx| { + let result = plugin_host::plugins_dir() + .and_then(|plugins| { + let data = plugins.parent()?; + Some(plugin_package::remove_managed( + &id, + &plugins, + &plugin_package::receipts_dir(data), + )) + }) + .unwrap_or_else(|| { + Err(anyhow::anyhow!( + "qrate application data is unavailable" + )) + }); + match result { + Ok(()) => { + settings::plugins::set_enabled(&id, false, cx); + plugin_host::reload(cx); + } + Err(error) => { + log::error!( + "could not remove managed plugin {id}: {error:#}" + ); + } + } + }); + }) + .detach(); + }) + .into_any_element() + }), + ) + .description(format!( + "Version {}. Source: {}. Integrity: recorded SHA-256 and package size.", + receipt.version, + match receipt.source { + plugin_package::InstallSource::OfficialCatalog => "Official catalog", + plugin_package::InstallSource::DirectGithub => "Direct GitHub install", + } + )), + ); + } + page = page.group(group); } page diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 1d883313..a7e90ede 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -459,9 +459,17 @@ fn main() { // First, so failures in GPUI platform construction and startup still reach the log file. logging::init(); let app = gpui_platform::application().with_assets(assets::Assets); + let initial_link = std::env::args().find(|argument| argument.starts_with("qrate://")); + let (url_sender, url_receiver) = async_channel::unbounded(); + app.on_open_urls(move |urls| { + for url in urls { + let _ = url_sender.try_send(url); + } + }); app.run(move |cx| { gpui_component::init(cx); + cx.register_url_scheme("qrate").detach(); // Settings ------------------------------------ let settings = load_app_settings().unwrap_or_default(); @@ -570,6 +578,13 @@ fn main() { cx.quit(); }); + cx.spawn(async move |cx| { + while let Ok(link) = url_receiver.recv().await { + cx.update(|cx| open_install_link(&link, cx)); + } + }) + .detach(); + // Flush before exit: writers debounce 450ms, and dock toggles/resizes never emit `LayoutChanged`. cx.on_app_quit(|cx| { flush_all_state(cx); @@ -577,7 +592,23 @@ fn main() { }) .detach(); - // The launcher is the real startup window; it opens the main window or the wizard itself. - project_wizard::open_launcher_window(cx); + match initial_link { + Some(link) if open_install_link(&link, cx) => {} + // The launcher is the normal startup window; it opens the main window or the wizard. + _ => project_wizard::open_launcher_window(cx), + } }); } + +fn open_install_link(link: &str, cx: &mut gpui::App) -> bool { + match plugin_package::parse_install_link(link) { + Ok(target) => { + plugin_marketplace::open_install_target(target, cx); + true + } + Err(error) => { + log::warn!("ignored invalid plugin install link: {error:#}"); + false + } + } +} diff --git a/crates/app/src/plugin_marketplace.rs b/crates/app/src/plugin_marketplace.rs index 401f1da1..f6e2ce69 100644 --- a/crates/app/src/plugin_marketplace.rs +++ b/crates/app/src/plugin_marketplace.rs @@ -13,7 +13,7 @@ use gpui_component::{ label::Label, v_flex, }; use plugin_package::{ - CatalogPlugin, DirectRelease, InstallSource, PackageInspection, ReleaseStatus, + CatalogPlugin, DirectRelease, InstallSource, InstallTarget, PackageInspection, ReleaseStatus, }; use window_wrapper::WindowRegistry; @@ -42,21 +42,37 @@ pub struct MarketplaceWindow { direct: DirectState, input: Entity, status: Option>, + requested_id: Option, + direct_source: Option, } impl MarketplaceWindow { - fn new(direct: bool, window: &mut Window, cx: &mut Context) -> Self { + fn new( + direct: bool, + target: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { window.set_window_title("qrate plugins"); let input = cx.new(|cx| InputState::new(window, cx).placeholder("https://github.com/owner/plugin")); + let (requested_id, direct_source) = match target { + Some(InstallTarget::Registry(id)) => (Some(id), None), + Some(InstallTarget::Github(source)) => (None, Some(source)), + None => (None, None), + }; let mut this = Self { catalog: CatalogState::Loading, direct: DirectState::Idle, input, - status: None, + status: requested_id + .as_ref() + .map(|id| format!("Reviewing official catalog entry {id}").into()), + requested_id, + direct_source, }; this.refresh(cx); - if direct { + if direct && this.direct_source.is_none() { this.input.focus_handle(cx).focus(window, cx); } this @@ -74,7 +90,16 @@ impl MarketplaceWindow { anyhow::anyhow!("this build has no plugin catalog public key") })?; let key = plugin_package::public_key(&key)?; - plugin_package::fetch_catalog(plugin_package::CATALOG_URL, &key) + let plugins = plugin_host::plugins_dir() + .ok_or_else(|| anyhow::anyhow!("qrate application data is unavailable"))?; + let data = plugins + .parent() + .ok_or_else(|| anyhow::anyhow!("plugin folder has no parent"))?; + plugin_package::fetch_catalog_cached( + plugin_package::CATALOG_URL, + &key, + &data.join("plugin-catalog"), + ) }) .await; this.update(cx, |this, cx| { @@ -90,7 +115,10 @@ impl MarketplaceWindow { } fn resolve_direct(&mut self, cx: &mut Context) { - let source = self.input.read(cx).value().to_string(); + let source = self + .direct_source + .clone() + .unwrap_or_else(|| self.input.read(cx).value().to_string()); if source.trim().is_empty() { self.direct = DirectState::Error("Paste a public GitHub repository or release URL".into()); @@ -140,6 +168,14 @@ impl MarketplaceWindow { cx.notify(); return; } + let already_managed = plugin_host::plugins_dir().is_some_and(|plugins| { + plugins.parent().is_some_and(|data| { + plugin_package::read_receipt(&plugin_package::receipts_dir(data), &plugin.id) + .ok() + .flatten() + .is_some() + }) + }); let permissions = if plugin.current.permissions.is_empty() { "No optional permissions".to_string() } else { @@ -189,12 +225,16 @@ impl MarketplaceWindow { this.update(cx, |this, cx| { match result { Ok(receipt) => { - settings::plugins::set_enabled(&receipt.id, false, cx); + if !already_managed { + settings::plugins::set_enabled(&receipt.id, false, cx); + } plugin_host::reload(cx); this.status = Some( format!( - "{} {} installed disabled. Enable it in Settings.", - receipt.id, receipt.version + "{} {} installed{}. Manage it in Settings.", + receipt.id, + receipt.version, + if already_managed { "" } else { " disabled" } ) .into(), ); @@ -250,26 +290,28 @@ impl MarketplaceWindow { .parent() .ok_or_else(|| anyhow::anyhow!("plugin folder has no parent"))?; let receipts = plugin_package::receipts_dir(data); - let result = plugin_package::install_archive( - &archive, - &plugins, - &receipts, - InstallSource::DirectGithub, - None, + let already_managed = + plugin_package::read_receipt(&receipts, &inspection.manifest.id)?.is_some(); + let result = plugin_package::install_direct_archive( + &archive, &plugins, &receipts, &release, ); let _ = std::fs::remove_file(archive); - result + result.map(|receipt| (receipt, already_managed)) }) .await; this.update(cx, |this, cx| { match result { - Ok(receipt) => { - settings::plugins::set_enabled(&receipt.id, false, cx); + Ok((receipt, already_managed)) => { + if !already_managed { + settings::plugins::set_enabled(&receipt.id, false, cx); + } plugin_host::reload(cx); this.status = Some( format!( - "{} {} installed disabled. Enable it in Settings.", - receipt.id, receipt.version + "{} {} installed{}. Manage it in Settings.", + receipt.id, + receipt.version, + if already_managed { "" } else { " disabled" } ) .into(), ); @@ -314,42 +356,54 @@ impl Render for MarketplaceWindow { } CatalogState::Ready(plugins) => v_flex() .gap_2() - .children(plugins.iter().cloned().map(|plugin| { - let button_plugin = plugin.clone(); - h_flex() - .justify_between() - .gap_3() - .p_3() - .border_1() - .border_color(cx.theme().border) - .child( - v_flex() - .child(Label::new(plugin.name).font_semibold()) + .children( + plugins + .iter() + .filter(|plugin| { + self.requested_id.as_ref().is_none_or(|id| id == &plugin.id) + }) + .cloned() + .map(|plugin| { + let button_plugin = plugin.clone(); + h_flex() + .justify_between() + .gap_3() + .p_3() + .border_1() + .border_color(cx.theme().border) .child( - Label::new(format!( - "{} · {} · API {}", - plugin.summary, - plugin.current.version, - plugin.current.api_version - )) - .text_sm() - .text_color(cx.theme().muted_foreground), - ), - ) - .child( - Button::new(format!("install-{}", plugin.id)) - .label(if plugin.current.status == ReleaseStatus::Revoked { - "Revoked" - } else { - "Install" - }) - .small() - .disabled(plugin.current.status == ReleaseStatus::Revoked) - .on_click(cx.listener(move |this, _, window, cx| { - this.install_official(button_plugin.clone(), window, cx); - })), - ) - })) + v_flex() + .child(Label::new(plugin.name).font_semibold()) + .child( + Label::new(format!( + "{} · {} · API {}", + plugin.summary, + plugin.current.version, + plugin.current.api_version + )) + .text_sm() + .text_color(cx.theme().muted_foreground), + ), + ) + .child( + Button::new(format!("install-{}", plugin.id)) + .label(if plugin.current.status == ReleaseStatus::Revoked { + "Revoked" + } else { + "Install" + }) + .small() + .disabled(plugin.current.status == ReleaseStatus::Revoked) + .on_click(cx.listener(move |this, _, window, cx| { + this.install_official( + button_plugin.clone(), + window, + cx, + ); + })), + ) + }), + ) .into_any_element(), }; @@ -413,6 +467,13 @@ impl Render for MarketplaceWindow { .child(Label::new("Official catalog").text_lg().font_semibold()) .child(catalog) .child(Label::new("Install from GitHub").text_lg().font_semibold()) + .when_some(self.direct_source.clone(), |view, source| { + view.child( + Label::new(format!("Requested source: {source}")) + .text_sm() + .text_color(cx.theme().muted_foreground), + ) + }) .child( h_flex() .gap_2() @@ -438,6 +499,14 @@ impl Render for MarketplaceWindow { } pub fn open_marketplace_window(direct: bool, cx: &mut gpui::App) { + open_marketplace(direct, None, cx); +} + +pub fn open_install_target(target: InstallTarget, cx: &mut gpui::App) { + open_marketplace(matches!(target, InstallTarget::Github(_)), Some(target), cx); +} + +fn open_marketplace(direct: bool, target: Option, cx: &mut gpui::App) { if WindowRegistry::focus_or_clear(MARKETPLACE_WINDOW_KIND, cx).is_some() { return; } @@ -449,7 +518,7 @@ pub fn open_marketplace_window(direct: bool, cx: &mut gpui::App) { ..Default::default() }; if let Ok(handle) = cx.open_window(options, |window, cx| { - let view = cx.new(|cx| MarketplaceWindow::new(direct, window, cx)); + let view = cx.new(|cx| MarketplaceWindow::new(direct, target, window, cx)); cx.new(|cx| Root::new(view, window, cx)) }) { WindowRegistry::register(MARKETPLACE_WINDOW_KIND, handle.into(), cx); diff --git a/crates/plugin-host/src/lib.rs b/crates/plugin-host/src/lib.rs index 30eed454..9cfd7f6e 100644 --- a/crates/plugin-host/src/lib.rs +++ b/crates/plugin-host/src/lib.rs @@ -697,12 +697,12 @@ fn modules(dir: &Path) -> Vec<(String, String)> { } fn search_paths() -> Vec { - // The working directory comes second so the repo's own plugins load from `cargo run` without a - // copy step. Drop it once there is an installer. - plugins_dir() - .into_iter() - .chain([PathBuf::from("plugins")]) - .collect() + let mut paths: Vec<_> = plugins_dir().into_iter().collect(); + // Keep repository plugins convenient during development without making the process working + // directory part of the installed application's trust boundary. + #[cfg(debug_assertions)] + paths.push(PathBuf::from("plugins")); + paths } #[cfg(test)] diff --git a/crates/plugin-package/src/lib.rs b/crates/plugin-package/src/lib.rs index 28c72408..e6964aba 100644 --- a/crates/plugin-package/src/lib.rs +++ b/crates/plugin-package/src/lib.rs @@ -22,6 +22,20 @@ pub const MAX_CATALOG_BYTES: usize = 5 * 1024 * 1024; pub const MAX_PACKAGE_BYTES: u64 = 100 * 1024 * 1024; pub const MAX_EXPANDED_BYTES: u64 = 500 * 1024 * 1024; pub const MAX_PACKAGE_FILES: usize = 10_000; +pub const SUPPORTED_API_VERSION: u64 = 1; +pub const ACCEPTED_LICENSES: &[&str] = &[ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC-BY-4.0", + "GPL-3.0-only", + "GPL-3.0-or-later", + "LGPL-3.0-only", + "LGPL-3.0-or-later", + "MIT", + "Unlicense", + "Zlib", +]; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct PackageManifest { @@ -138,6 +152,72 @@ pub enum InstallSource { DirectGithub, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InstallTarget { + Registry(String), + Github(String), +} + +pub fn parse_install_link(value: &str) -> Result { + let url = reqwest::Url::parse(value).context("invalid qrate install link")?; + ensure!( + url.scheme() == "qrate" + && url.host_str() == Some("plugin") + && url.path() == "/install" + && url.username().is_empty() + && url.password().is_none() + && url.fragment().is_none(), + "unsupported qrate install link" + ); + let pairs: Vec<_> = url.query_pairs().collect(); + let one = |name: &str| -> Result> { + let values: Vec<_> = pairs + .iter() + .filter(|(key, _)| key == name) + .map(|(_, value)| value.to_string()) + .collect(); + ensure!(values.len() <= 1, "duplicate {name} parameter"); + Ok(values.into_iter().next()) + }; + let source = one("source")?.context("install link has no source")?; + let allowed: &[&str] = match source.as_str() { + "registry" => &["source", "id"], + "github" => &["source", "repo"], + "url" => &["source", "release"], + _ => bail!("unsupported install source"), + }; + ensure!( + pairs.iter().all(|(key, _)| allowed.contains(&key.as_ref())), + "install link has an unknown parameter" + ); + match source.as_str() { + "registry" => { + let id = one("id")?.context("registry install link has no plugin ID")?; + validate_id(&id)?; + Ok(InstallTarget::Registry(id)) + } + "github" => { + let repo = one("repo")?.context("GitHub install link has no repository")?; + let mut parts = repo.split('/'); + let owner = parts.next().unwrap_or_default(); + let repository = parts.next().unwrap_or_default(); + ensure!( + !owner.is_empty() && !repository.is_empty() && parts.next().is_none(), + "GitHub repository must be owner/name" + ); + let source = format!("https://github.com/{owner}/{repository}"); + validate_github_url(&source)?; + Ok(InstallTarget::Github(source)) + } + "url" => { + let release = one("release")?.context("GitHub install link has no release URL")?; + validate_github_url(&release)?; + Ok(InstallTarget::Github(release)) + } + _ => unreachable!(), + } +} + pub fn public_key(base64url: &str) -> Result { let bytes: [u8; 32] = URL_SAFE_NO_PAD .decode(base64url) @@ -186,6 +266,34 @@ pub fn verify_catalog(bytes: &[u8], signature_bytes: &[u8], key: &VerifyingKey) } pub fn fetch_catalog(url: &str, key: &VerifyingKey) -> Result { + let (bytes, signature) = fetch_catalog_files(url)?; + verify_catalog(&bytes, &signature, key) +} + +pub fn fetch_catalog_cached(url: &str, key: &VerifyingKey, cache_root: &Path) -> Result { + let catalog_path = cache_root.join("catalog.json"); + let signature_path = cache_root.join("catalog.json.sig"); + match fetch_catalog_files(url).and_then(|(catalog, signature)| { + let verified = verify_catalog(&catalog, &signature, key)?; + fs::create_dir_all(cache_root)?; + write_bytes_atomic(&catalog_path, &catalog)?; + write_bytes_atomic(&signature_path, &signature)?; + Ok(verified) + }) { + Ok(catalog) => Ok(catalog), + Err(download_error) => { + let catalog = fs::read(&catalog_path) + .context("catalog refresh failed and no cached catalog is available")?; + let signature = fs::read(&signature_path) + .context("catalog refresh failed and no cached signature is available")?; + verify_catalog(&catalog, &signature, key).with_context(|| { + format!("catalog refresh failed ({download_error:#}) and cached catalog is invalid") + }) + } + } +} + +fn fetch_catalog_files(url: &str) -> Result<(Vec, Vec)> { let client = reqwest::blocking::Client::builder() .timeout(Duration::from_secs(20)) .build()?; @@ -193,7 +301,7 @@ pub fn fetch_catalog(url: &str, key: &VerifyingKey) -> Result { .context("could not download plugin catalog")?; let signature = bounded_response(client.get(format!("{url}.sig")).send()?, 64 * 1024) .context("could not download plugin catalog signature")?; - verify_catalog(&bytes, &signature, key) + Ok((bytes, signature)) } fn bounded_response(response: reqwest::blocking::Response, limit: u64) -> Result> { @@ -224,34 +332,15 @@ pub fn download_package(url: &str, output: &Path) -> Result<(String, u64)> { } pub fn resolve_github_release(source: &str) -> Result { - let url = reqwest::Url::parse(source).context("invalid GitHub URL")?; - ensure!( - url.scheme() == "https" && url.host_str() == Some("github.com"), - "only public HTTPS GitHub repositories are supported" - ); - ensure!( - url.username().is_empty() && url.password().is_none(), - "GitHub URL must not contain credentials" - ); + let url = validate_github_url(source)?; let parts: Vec<_> = url .path_segments() .into_iter() .flatten() .filter(|part| !part.is_empty()) .collect(); - ensure!( - parts.len() >= 2, - "GitHub URL must name an owner and repository" - ); let owner = parts[0]; let repository = parts[1].trim_end_matches(".git"); - ensure!( - owner - .bytes() - .chain(repository.bytes()) - .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)), - "GitHub owner or repository is invalid" - ); let endpoint = if parts.get(2..4) == Some(&["releases", "tag"]) && parts.len() == 5 { format!( @@ -308,6 +397,42 @@ pub fn resolve_github_release(source: &str) -> Result { }) } +fn validate_github_url(source: &str) -> Result { + let url = reqwest::Url::parse(source).context("invalid GitHub URL")?; + ensure!( + url.scheme() == "https" && url.host_str() == Some("github.com"), + "only public HTTPS GitHub repositories are supported" + ); + ensure!( + url.username().is_empty() && url.password().is_none(), + "GitHub URL must not contain credentials" + ); + let parts: Vec<_> = url + .path_segments() + .into_iter() + .flatten() + .filter(|part| !part.is_empty()) + .collect(); + ensure!( + parts.len() >= 2, + "GitHub URL must name an owner and repository" + ); + let owner = parts[0]; + let repository = parts[1].trim_end_matches(".git"); + ensure!( + owner + .bytes() + .chain(repository.bytes()) + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)), + "GitHub owner or repository is invalid" + ); + ensure!( + parts.len() == 2 || (parts.get(2..4) == Some(&["releases", "tag"]) && parts.len() == 5), + "unsupported GitHub release URL" + ); + Ok(url) +} + pub fn inspect_archive(archive: &Path) -> Result { let (sha256, bytes) = sha256_file(archive)?; ensure!(bytes <= MAX_PACKAGE_BYTES, "plugin package is too large"); @@ -332,6 +457,33 @@ pub fn install_archive( receipts_root: &Path, source: InstallSource, expected: Option<(&CatalogPlugin, &CatalogRelease)>, +) -> Result { + install_archive_inner(archive, plugins_root, receipts_root, source, expected, None) +} + +pub fn install_direct_archive( + archive: &Path, + plugins_root: &Path, + receipts_root: &Path, + release: &DirectRelease, +) -> Result { + install_archive_inner( + archive, + plugins_root, + receipts_root, + InstallSource::DirectGithub, + None, + Some(release), + ) +} + +fn install_archive_inner( + archive: &Path, + plugins_root: &Path, + receipts_root: &Path, + source: InstallSource, + expected: Option<(&CatalogPlugin, &CatalogRelease)>, + direct: Option<&DirectRelease>, ) -> Result { let (sha256, bytes) = sha256_file(archive)?; ensure!(bytes <= MAX_PACKAGE_BYTES, "plugin package is too large"); @@ -396,6 +548,24 @@ pub fn install_archive( !target.exists() || receipt_path.exists(), "an unmanaged plugin already uses this package ID" ); + if let Some(installed) = read_receipt(receipts_root, &manifest.id)? { + ensure!( + installed.source == source, + "installed plugin comes from a different source" + ); + if let Some((plugin, _)) = expected { + ensure!( + installed.repository == plugin.repository, + "installed plugin belongs to a different repository" + ); + } + if let Some(release) = direct { + ensure!( + installed.repository == release.repository, + "installed plugin belongs to a different repository" + ); + } + } let rollback = plugins_root.join(format!(".{}.rollback", manifest.id)); if rollback.exists() { fs::remove_dir_all(&rollback)?; @@ -418,11 +588,18 @@ pub fn install_archive( version: manifest.version, source, repository: expected.map_or_else( - || manifest.homepage.clone(), + || { + direct.map_or_else( + || manifest.homepage.clone(), + |release| release.repository.clone(), + ) + }, |(plugin, _)| plugin.repository.clone(), ), - artifact_url: expected - .map_or_else(String::new, |(_, release)| release.artifact_url.clone()), + artifact_url: expected.map_or_else( + || direct.map_or_else(String::new, |release| release.artifact_url.clone()), + |(_, release)| release.artifact_url.clone(), + ), sha256, bytes, }; @@ -474,7 +651,32 @@ fn validate_catalog(catalog: &Catalog) -> Result<()> { for plugin in &catalog.plugins { validate_id(&plugin.id)?; ensure!(ids.insert(&plugin.id), "duplicate plugin ID {}", plugin.id); + ensure!(!plugin.name.trim().is_empty(), "plugin name is empty"); + ensure!( + ACCEPTED_LICENSES.contains(&plugin.license.as_str()), + "plugin {} uses an unsupported license", + plugin.id + ); + ensure!( + plugin.current.api_version > 0 && plugin.current.api_version <= SUPPORTED_API_VERSION, + "plugin {} needs an unsupported API version", + plugin.id + ); + ensure!( + plugin.current.bytes <= MAX_PACKAGE_BYTES, + "plugin {} package is too large", + plugin.id + ); validate_sha256(&plugin.current.sha256)?; + validate_https_url(&plugin.repository)?; + validate_https_url(&plugin.current.release_url)?; + validate_https_url(&plugin.current.artifact_url)?; + ensure!( + plugin.current.status == ReleaseStatus::Revoked + || plugin.current.revocation_reason.is_none(), + "active plugin {} has a revocation reason", + plugin.id + ); ensure!( plugin .current @@ -495,9 +697,14 @@ fn validate_manifest(manifest: &PackageManifest, root: &Path) -> Result<()> { validate_id(&manifest.id)?; ensure!(!manifest.name.trim().is_empty(), "package name is empty"); ensure!( - manifest.api_version > 0, - "package API version must be positive" + manifest.api_version > 0 && manifest.api_version <= SUPPORTED_API_VERSION, + "package needs an unsupported API version" + ); + ensure!( + ACCEPTED_LICENSES.contains(&manifest.license.as_str()), + "package uses an unsupported license" ); + validate_https_url(&manifest.homepage)?; let mut entry = Path::new(&manifest.entry).components(); ensure!( matches!(entry.next(), Some(Component::Normal(_))) && entry.next().is_none(), @@ -555,6 +762,18 @@ fn validate_sha256(value: &str) -> Result<()> { Ok(()) } +fn validate_https_url(value: &str) -> Result<()> { + let url = reqwest::Url::parse(value).context("invalid HTTPS URL")?; + ensure!( + url.scheme() == "https" + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none(), + "URL must be public HTTPS without credentials" + ); + Ok(()) +} + fn extract_package(archive: &Path, output: &Path) -> Result<()> { let file = fs::File::open(archive)?; let mut archive = zip::ZipArchive::new(file).context("invalid plugin ZIP")?; @@ -617,6 +836,15 @@ fn write_json_atomic(path: &Path, value: &impl Serialize) -> Result<()> { Ok(()) } +fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<()> { + fs::create_dir_all(path.parent().context("cache file has no parent")?)?; + let mut temp = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; + temp.write_all(bytes)?; + temp.as_file_mut().sync_all()?; + temp.persist(path).map_err(|error| error.error)?; + Ok(()) +} + #[cfg(test)] mod tests { use std::fs; @@ -630,8 +858,8 @@ mod tests { use zip::{ZipWriter, write::SimpleFileOptions}; use super::{ - CATALOG_KEY_ID, InstallSource, install_archive, read_receipt, remove_managed, - verify_catalog, + CATALOG_KEY_ID, InstallSource, InstallTarget, install_archive, parse_install_link, + read_receipt, remove_managed, verify_catalog, }; fn package(path: &std::path::Path, id: &str, extra: Option<(&str, &[u8])>) { @@ -695,6 +923,29 @@ mod tests { ); } + #[test] + fn install_links_accept_only_reviewable_sources() { + assert_eq!( + parse_install_link("qrate://plugin/install?source=registry&id=org.example.plugin") + .unwrap(), + InstallTarget::Registry("org.example.plugin".into()) + ); + assert_eq!( + parse_install_link("qrate://plugin/install?source=github&repo=owner/plugin").unwrap(), + InstallTarget::Github("https://github.com/owner/plugin".into()) + ); + assert!( + parse_install_link("qrate://plugin/install?source=url&release=file:///tmp/plugin.zip") + .is_err() + ); + assert!( + parse_install_link( + "qrate://plugin/install?source=registry&id=org.example.plugin&extra=yes" + ) + .is_err() + ); + } + #[test] fn installs_and_removes_a_managed_package() { let root = tempdir().unwrap(); From 476234f97781161846af79813ab8530cd00ac5b4 Mon Sep 17 00:00:00 2001 From: devnull03 <56480041+devnull03@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:54:19 -0700 Subject: [PATCH 07/28] feat(packaging): register plugin install links --- .github/workflows/release.yml | 3 ++- assets/macos/Info.plist | 11 +++++++++++ scripts/installer.nsi | 7 +++++++ scripts/installer.wxs | 12 ++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 766bd098..0a9b2a0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -312,7 +312,8 @@ jobs: chmod +x scripts/fetch-agent-runtime.sh ./scripts/fetch-agent-runtime.sh "$stage" linux-x64 printf '%s\n' '[Desktop Entry]' 'Type=Application' 'Name=qrate' \ - 'Exec=qrate' 'Icon=qrate' 'Categories=Office;Database;' \ + 'Exec=qrate %u' 'Icon=qrate' 'Terminal=false' \ + 'Categories=Office;Database;' 'MimeType=x-scheme-handler/qrate;' \ > "$stage/qrate.desktop" mkdir -p dist tar -czf "dist/$stage.tar.gz" "$stage" diff --git a/assets/macos/Info.plist b/assets/macos/Info.plist index 3a858c11..5bf64cb3 100644 --- a/assets/macos/Info.plist +++ b/assets/macos/Info.plist @@ -20,6 +20,17 @@ __VERSION__ CFBundleVersion __VERSION__ + CFBundleURLTypes + + + CFBundleURLName + io.github.devnull03.qrate.plugin-install + CFBundleURLSchemes + + qrate + + + LSMinimumSystemVersion 10.15 NSHighResolutionCapable diff --git a/scripts/installer.nsi b/scripts/installer.nsi index d3188086..08760657 100644 --- a/scripts/installer.nsi +++ b/scripts/installer.nsi @@ -131,6 +131,12 @@ Section "Install" CreateShortcut "$SMPROGRAMS\${APPNAME}.lnk" "$INSTDIR\${EXENAME}" CreateShortcut "$DESKTOP\${APPNAME}.lnk" "$INSTDIR\${EXENAME}" + ; Browser install links always pass one quoted URI argument to qrate's strict parser. + WriteRegStr SHCTX "Software\Classes\qrate" "" "URL:qrate Protocol" + WriteRegStr SHCTX "Software\Classes\qrate" "URL Protocol" "" + WriteRegStr SHCTX "Software\Classes\qrate\DefaultIcon" "" "$INSTDIR\${EXENAME},0" + WriteRegStr SHCTX "Software\Classes\qrate\shell\open\command" "" '"$INSTDIR\${EXENAME}" "%1"' + ; Uninstaller + Add/Remove Programs entry. SHCTX is HKLM for an all-users install, HKCU for a ; per-user one — set by MULTIUSER_INIT to match the mode picked above. WriteUninstaller "$INSTDIR\Uninstall.exe" @@ -166,6 +172,7 @@ Section "un.qrate" SEC_UNAPP RMDir "$INSTDIR" Delete "$SMPROGRAMS\${APPNAME}.lnk" Delete "$DESKTOP\${APPNAME}.lnk" + DeleteRegKey SHCTX "Software\Classes\qrate" DeleteRegKey SHCTX "${UNINSTKEY}" DeleteRegKey SHCTX "Software\${APPNAME}" diff --git a/scripts/installer.wxs b/scripts/installer.wxs index 96265651..04ce7bd8 100644 --- a/scripts/installer.wxs +++ b/scripts/installer.wxs @@ -73,6 +73,18 @@ + + + + + + + + + + + +