diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 766bd098..727318f7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -23,6 +23,9 @@ env:
# and nothing more — see docs/dev/site-oauth-handoff.md. Absent: the endpoint answers 401 and the
# app carries on with the pair above.
QRATE_GOOGLE_CONFIG_TOKEN: ${{ secrets.QRATE_GOOGLE_CONFIG_TOKEN }}
+ # Public Ed25519 key for the independently signed plugin catalog. Release builds embed it; local
+ # builds can set the same variable at compile time or use it at runtime for development.
+ QRATE_PLUGIN_CATALOG_PUBLIC_KEY: ${{ vars.QRATE_PLUGIN_CATALOG_PUBLIC_KEY }}
jobs:
# ---------------------------------------------------------------------------
@@ -312,7 +315,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/CLAUDE.md b/CLAUDE.md
index 647cb02d..d0671af0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -152,7 +152,7 @@ executable, optionally — a package built without them installs a working, degr
- Only `crates/app` initializes logging (`crates/app/src/logging.rs`, called first in `main`). It writes `%LOCALAPPDATA%\qrate\logs\qrate.log` (previous run rotated to `qrate.old.log`) and installs the panic hook.
- That file is also what Help ▸ Copy Debug Info / Report an Issue paste. Anything logged at `error`/`warn` will end up in a user's bug report, so write the message for the person reading the report — name what failed, not just the error value.
-## Plugin API — it lives in three repos, change all of them
+## Plugin contracts — keep all repositories synchronized
The Lua plugin surface (`crates/plugin-host`, `crates/plugin-api`) is published outside this repo,
and Luau has no package manager — a plugin is a folder somebody drops in by hand, so the type
@@ -163,7 +163,8 @@ everyone's editor completion, and nothing in CI would say so.
|---|---|---|
| [`qrate-plugin-template`](https://github.com/devnull03/qrate-plugin-template) | `../qrate-plugin-template` | `types/qrate.lua` — the canonical copy — and the example plugin |
| [`qrate-islandora-plugin`](https://github.com/devnull03/qrate-islandora-plugin) | `plugins/islandora` (gitignored here) | its own copy of `types/qrate.lua` |
-| `qrate-site` | `../qrate-site` — the `site` branch of *this* repo, checked out separately | nothing about plugins yet — include it once it documents them |
+| [`qrate-plugin-registry`](https://github.com/devnull03/qrate-plugin-registry) | `../qrate-plugin-registry` | package and listing schemas, reviewed records, and the signed catalog |
+| `qrate-site` | `../qrate-site` — the `site` branch of *this* repo, checked out separately | catalog-backed marketplace and submission documentation |
**When you add, rename, or remove anything a plugin can see** — a host function, a descriptor field,
a `SettingKind`, a hook's arguments, what a scope contains:
@@ -179,9 +180,11 @@ a `SettingKind`, a hook's arguments, what a scope contains:
from disk, so they are the check that the copies still work. They are ignored, so nothing else
will catch it.
-The two plugin repos have their own git history and their own commit. Do not add them as
-submodules — qrate neither pins nor carries them. `qrate-site` is a branch here, so its commit
-lands on this remote.
+The plugin repositories have their own git history and commits. Do not add them as submodules —
+qrate neither pins nor carries them. A package-manifest change must also update the registry's
+`package.schema.json` and template checks. A catalog contract change must update the registry,
+`plugin-package`, and the site's catalog loader. `qrate-site` is a branch here, so its commit lands
+on this remote.
## gpui test modules
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index eeb934cd..6194901e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -56,7 +56,7 @@ CI cancels older runs for the same branch when a newer commit arrives. A passing
Keep pull requests small enough to review. Explain user-visible changes, note limitations, and include screenshots or a short recording for visual changes when practical. Do not commit secrets, private collection data, or generated build output.
-Plugins are a public compatibility surface. If a contribution changes the plugin API, read the repository guidance in [`CLAUDE.md`](CLAUDE.md) before making the change; the API has matching type definitions in companion repositories.
+Plugins are a public compatibility surface. If a contribution changes the plugin API, read the repository guidance in [`CLAUDE.md`](CLAUDE.md) before making the change; the API has matching type definitions in companion repositories. Start new plugins from [`qrate-plugin-template`](https://github.com/devnull03/qrate-plugin-template), and submit versioned release packages through [`qrate-plugin-registry`](https://github.com/devnull03/qrate-plugin-registry).
## License
diff --git a/Cargo.lock b/Cargo.lock
index 57a19996..81af60e2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -272,6 +272,7 @@ dependencies = [
"os_info",
"plugin-api",
"plugin-host",
+ "plugin-package",
"preview",
"project-wizard",
"schemars",
@@ -280,9 +281,11 @@ dependencies = [
"serde_json",
"settings",
"simplelog",
+ "single-instance",
"spellcheck",
"sysinfo 0.37.2",
"table",
+ "tempfile",
"updater",
"window-wrapper",
"winresource",
@@ -4549,6 +4552,15 @@ dependencies = [
"libc",
]
+[[package]]
+name = "memoffset"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "memoffset"
version = "0.9.1"
@@ -4748,6 +4760,19 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+[[package]]
+name = "nix"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c"
+dependencies = [
+ "bitflags 1.3.2",
+ "cc",
+ "cfg-if",
+ "libc",
+ "memoffset 0.6.5",
+]
+
[[package]]
name = "nix"
version = "0.29.0"
@@ -4758,7 +4783,7 @@ dependencies = [
"cfg-if",
"cfg_aliases",
"libc",
- "memoffset",
+ "memoffset 0.9.1",
]
[[package]]
@@ -5874,6 +5899,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"
@@ -7369,6 +7410,19 @@ dependencies = [
"time",
]
+[[package]]
+name = "single-instance"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4637485391f8545c9d3dbf60f9d9aab27a90c789a700999677583bcb17c8795d"
+dependencies = [
+ "libc",
+ "nix 0.23.2",
+ "thiserror 1.0.69",
+ "widestring",
+ "winapi",
+]
+
[[package]]
name = "siphasher"
version = "1.0.2"
@@ -8573,7 +8627,7 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca"
dependencies = [
- "memoffset",
+ "memoffset 0.9.1",
"tempfile",
"windows-sys 0.61.2",
]
@@ -9386,6 +9440,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "widestring"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c168940144dd21fd8046987c16a46a33d5fc84eec29ef9dcddc2ac9e31526b7c"
+
[[package]]
name = "winapi"
version = "0.3.9"
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/README.md b/README.md
index 9df2c5e1..236cff88 100644
--- a/README.md
+++ b/README.md
@@ -70,7 +70,7 @@ qrate works without an account. Google Sheets integration is off until you enabl
- [Columns](docs/columns.md) — types, authority lists, and project settings
- [Export and Google Sheets](docs/export-and-sync.md) — move data in and out
- [Agent panel](docs/agent-panel.md) — review data with an optional local agent
-- [Plugins](docs/plugins/index.md) — extend qrate locally
+- [Plugins](docs/plugins/index.md) — discover, install, or write extensions
## Contributing
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/crates/app/Cargo.toml b/crates/app/Cargo.toml
index 89635048..32603932 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -15,10 +15,12 @@ simplelog.workspace = true
os_info.workspace = true
sysinfo.workspace = true
async-channel = "2"
+single-instance = "0.3.3"
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 +48,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..62b18735 100644
--- a/crates/app/src/app_menus.rs
+++ b/crates/app/src/app_menus.rs
@@ -2,7 +2,7 @@ use gpui::*;
use window_wrapper::OpenBrowser;
use crate::actions::{NewProject, Save, ToggleBottomDock, ToggleLeftDock, ToggleRightDock};
-use crate::export::{EXPORT_FORMATS, Export};
+use crate::export::{EXPORT_FORMATS, Export, PluginExport};
use crate::theming::{SwitchTheme, theme_choices};
// The Edit menu's items act on the grid, so they're the grid's actions — `table` declares and
@@ -20,6 +20,8 @@ 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,
+ ManagePlugins,
OpenPluginsFolder,
ReloadPlugins,
Quit,
@@ -49,6 +51,38 @@ fn planned(name: &'static str) -> MenuItem {
}
}
+fn export_items(cx: &gpui::App) -> Vec {
+ let mut items: Vec = EXPORT_FORMATS
+ .iter()
+ .map(|(format, label, _)| MenuItem::Action {
+ name: (*label).into(),
+ action: Box::new(Export { format: *format }),
+ os_action: None,
+ checked: false,
+ disabled: false,
+ })
+ .collect();
+ let plugin_exports = plugin_host::export_specs(cx);
+ if !plugin_exports.is_empty() {
+ items.push(MenuItem::Separator);
+ items.extend(
+ plugin_exports
+ .into_iter()
+ .map(|(plugin, export)| MenuItem::Action {
+ name: export.label,
+ action: Box::new(PluginExport {
+ plugin: plugin.to_string(),
+ export: export.id.to_string(),
+ }),
+ os_action: None,
+ checked: false,
+ disabled: false,
+ }),
+ );
+ }
+ items
+}
+
/// Installs the menu bar. Called at startup and again once the theme registry has loaded, since
/// the Theme submenu lists what the registry holds — see `theming::on_themes_loaded`.
///
@@ -73,16 +107,7 @@ fn app_menus(cx: &gpui::App) -> Vec {
MenuItem::submenu(Menu {
name: "Export".into(),
disabled: false,
- items: EXPORT_FORMATS
- .iter()
- .map(|(format, label, _)| MenuItem::Action {
- name: (*label).into(),
- action: Box::new(Export { format: *format }),
- os_action: None,
- checked: false,
- disabled: false,
- })
- .collect(),
+ items: export_items(cx),
}),
MenuItem::Separator,
MenuItem::action("Settings", OpenSettings),
@@ -179,9 +204,12 @@ fn app_menus(cx: &gpui::App) -> Vec {
],
},
Menu {
- name: "Extensions".into(),
+ name: "Plugins".into(),
disabled: false,
items: vec![
+ MenuItem::action("Discover Plugins…", DiscoverPlugins),
+ MenuItem::action("Manage Plugins…", ManagePlugins),
+ MenuItem::Separator,
MenuItem::action("Plugins Folder", OpenPluginsFolder),
MenuItem::action("Reload Plugins", ReloadPlugins),
MenuItem::Separator,
@@ -220,7 +248,7 @@ fn app_menus(cx: &gpui::App) -> Vec {
MenuItem::action(
"Releases",
OpenBrowser {
- url: "https://qrate.dvnl.work/releases".into(),
+ url: crate::site::url("/releases"),
},
),
MenuItem::Separator,
diff --git a/crates/app/src/app_settings/mod.rs b/crates/app/src/app_settings/mod.rs
index eb05ba74..4b389aa5 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 _,
@@ -29,29 +29,48 @@ 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![
- SettingPage::new("Application").group(
- SettingGroup::new().title("Updates").item(
- SettingItem::new(
- "Automatic updates",
- SettingField::switch(
- |cx: &App| crate::update_check::automatic_updates(cx),
- |on: bool, cx: &mut App| {
- settings::AppSettings::set_bool(updater::AUTO_UPDATE_KEY, on, cx);
+ SettingPage::new("Application")
+ .group(
+ divided_group(cx).title("Appearance").item(SettingItem::new(
+ "Theme",
+ SettingField::scrollable_dropdown(
+ crate::theming::theme_choices(cx)
+ .into_iter()
+ .map(|name| (name.clone(), name))
+ .collect(),
+ |cx: &App| cx.theme().theme_name().clone(),
+ |name: SharedString, cx: &mut App| {
+ cx.dispatch_action(&crate::theming::SwitchTheme {
+ name: name.to_string(),
+ });
},
),
- )
- .description(
- "Check for and download signed qrate updates in the background. Installing \
+ )),
+ )
+ .group(
+ divided_group(cx).title("Updates").item(
+ SettingItem::new(
+ "Automatic updates",
+ SettingField::switch(
+ |cx: &App| crate::update_check::automatic_updates(cx),
+ |on: bool, cx: &mut App| {
+ settings::AppSettings::set_bool(updater::AUTO_UPDATE_KEY, on, cx);
+ },
+ ),
+ )
+ .description(
+ "Check for and download signed qrate updates in the background. Installing \
always waits for you to choose Restart to update.",
+ ),
),
),
- ),
SettingPage::new("Table")
.group(
- SettingGroup::new().title("Appearance").item(
+ divided_group(cx).title("Appearance").item(
Setting::Switch {
key: table::TABLE_STRIPES_KEY,
label: "Row Stripes",
@@ -62,7 +81,7 @@ pub fn build_pages(cx: &App) -> Vec {
)
.group(saving_group(cx))
.group(notes_group(cx))
- .group(previews_group()),
+ .group(previews_group(cx)),
columns_page(cx),
project_page(cx),
SettingPage::new("Agent")
@@ -71,7 +90,7 @@ pub fn build_pages(cx: &App) -> Vec {
connection. It cannot change a cell — it can only stage findings you accept.",
)
.group(
- SettingGroup::new().title("Local bridge").item(
+ divided_group(cx).title("Local bridge").item(
Setting::Switch {
key: crate::agent_bridge::AGENT_BRIDGE_KEY,
label: "Allow agents to read this app",
@@ -82,7 +101,7 @@ pub fn build_pages(cx: &App) -> Vec {
),
)
.group(
- SettingGroup::new()
+ divided_group(cx)
.title("Terminal")
.item(
Setting::Text {
@@ -112,10 +131,16 @@ pub fn build_pages(cx: &App) -> Vec {
pages
}
+fn divided_group(cx: &App) -> SettingGroup {
+ SettingGroup::new()
+ .border_b_1()
+ .border_color(cx.theme().border)
+}
+
/// Who a filed note is attributed to. Lives beside the table's own preferences because a note is
/// filed from the grid, and the name signing it is the archivist's, not the project's.
fn notes_group(cx: &App) -> SettingGroup {
- SettingGroup::new().title("Notes").item(
+ divided_group(cx).title("Notes").item(
Setting::Text {
key: settings::NOTE_AUTHOR_KEY,
label: "Attribute notes to",
@@ -133,22 +158,45 @@ fn notes_group(cx: &App) -> SettingGroup {
/// no descriptor and switching a broken one off has to work.
fn plugins_page(cx: &App) -> SettingPage {
let listing = plugin_host::listing(cx);
+ let install = divided_group(cx)
+ .title("Find and install")
+ .description(
+ "Browse the official catalog on qrate.dvnl.work, or review a public GitHub release.",
+ )
+ .item(
+ SettingItem::render(|_opts: &_, window: &mut Window, cx: &mut App| {
+ window
+ .use_keyed_state("plugin-installer", cx, |window, cx| {
+ crate::plugin_marketplace::MarketplaceWindow::new(
+ false, None, true, window, cx,
+ )
+ })
+ .into_any_element()
+ })
+ .keywords(["plugins", "catalog", "GitHub", "install"]),
+ );
+ let mut page = SettingPage::new("Plugins")
+ .description(
+ "Installed plugins run in a sandbox and reach the network only when you grant access.",
+ )
+ .group(install);
if listing.is_empty() {
- return SettingPage::new("Plugins").group(
- SettingGroup::new()
+ return page.group(
+ divided_group(cx)
.title("Installed")
- .description("No plugins found. Extensions ▸ Plugins Folder is where they go."),
+ .description("No plugins found. Plugins ▸ Plugins Folder is where they go."),
);
}
- let mut page = SettingPage::new("Plugins").description(
- "Scripts found in the plugins folder. A plugin is code from somebody else, so it \
- reaches the network only if you say so.",
- );
-
for plugin in listing {
let id = plugin.id.clone();
- let mut group = SettingGroup::new().title(plugin.id.clone()).item({
+ 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 = divided_group(cx).title(plugin.name.clone()).item({
let switched = id.clone();
let item = SettingItem::new(
"Enabled",
@@ -170,7 +218,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,
}
});
@@ -201,11 +266,81 @@ fn plugins_page(cx: &App) -> SettingPage {
.description(SharedString::from(format!(
"Let {permission_owner} reach the internet. It asked for this; nothing \
stops it contacting any address once you agree.",
- permission_owner = plugin.id
+ permission_owner = plugin.name
))),
);
}
+ 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(()) => {
+ log::info!("managed plugin removed: {id}");
+ 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
@@ -235,7 +370,7 @@ fn plugin_pages(cx: &App) -> Vec {
}
if !specs.is_empty() {
page = page.group(
- SettingGroup::new().title("Options").items(
+ divided_group(cx).title("Options").items(
specs
.into_iter()
.map(|spec| plugin_item(name.clone(), spec)),
@@ -280,7 +415,7 @@ fn mapping_group(plugin: SharedString, spec: ColumnMapSpec, cx: &App) -> Setting
None => refresh,
};
- let mut group = SettingGroup::new().title("Mapping").item(refresh);
+ let mut group = divided_group(cx).title("Mapping").item(refresh);
let Some(project) = cx.try_global::() else {
return group.description("Open a project to map its columns.");
@@ -372,8 +507,8 @@ fn plugin_item(id: SharedString, spec: SettingSpec) -> SettingItem {
/// The cache looks after itself — entries are keyed by the file's mtime and size, so a stale one
/// is never served, and it drops its oldest once it passes a couple of gigabytes. The button is
/// for reclaiming the space now rather than for correctness.
-fn previews_group() -> SettingGroup {
- SettingGroup::new().title("Previews").item(
+fn previews_group(cx: &App) -> SettingGroup {
+ divided_group(cx).title("Previews").item(
SettingItem::new(
"Cached thumbnails",
SettingField::element(|_opts: &_, _window: &mut _, _cx: &mut _| {
@@ -400,7 +535,7 @@ fn previews_group() -> SettingGroup {
/// Google Sheets has three deliberately separate states: the app-wide feature switch, the
/// machine's OAuth grant, and the open project's chosen sync destination.
fn google_page(cx: &App) -> SettingPage {
- let mut group = SettingGroup::new().title("Google Sheets").item(
+ let mut group = divided_group(cx).title("Google Sheets").item(
SettingItem::new(
"Enable Google Sheets",
SettingField::render(|_opts: &_, _window: &mut _, cx: &mut App| {
@@ -569,7 +704,7 @@ fn google_page(cx: &App) -> SettingPage {
}
fn saving_group(cx: &App) -> SettingGroup {
- let mut group = SettingGroup::new().title("Saving").item(
+ let mut group = divided_group(cx).title("Saving").item(
SettingItem::new(
"Autosave",
SettingField::switch(
@@ -616,7 +751,7 @@ fn saving_group(cx: &App) -> SettingGroup {
/// the other per-column knobs. Changing either takes effect on the next launch, since the
/// dictionary is parsed once at startup.
fn spelling_group(cx: &App) -> SettingGroup {
- let mut group = SettingGroup::new().title("Spelling").item(
+ let mut group = divided_group(cx).title("Spelling").item(
SettingItem::new(
"Check spelling",
SettingField::switch(
@@ -1098,7 +1233,7 @@ fn columns_page(cx: &App) -> SettingPage {
let Some(project) = cx.try_global::() else {
return SettingPage::new("Columns")
.group(
- SettingGroup::new()
+ divided_group(cx)
.title("Filters")
.description("Open a project to configure its columns."),
)
@@ -1107,7 +1242,7 @@ fn columns_page(cx: &App) -> SettingPage {
let headers = column_items(project);
- let mut group = SettingGroup::new().title("Filters").item(
+ let mut group = divided_group(cx).title("Filters").item(
SettingItem::new(
"Enable column filters",
SettingField::switch(
@@ -1151,7 +1286,7 @@ fn columns_page(cx: &App) -> SettingPage {
}
let picked = headers.clone();
- let spelling = SettingGroup::new().title("Spelling").item(
+ let spelling = divided_group(cx).title("Spelling").item(
SettingItem::new(
"Spell-checked columns",
SettingField::element(move |_opts: &_, window: &mut _, cx: &mut _| {
@@ -1174,10 +1309,10 @@ fn columns_page(cx: &App) -> SettingPage {
.group(group)
.group(spelling)
.group(descriptions_group(project, &headers, cx))
- .group(data_types_group(headers))
+ .group(data_types_group(headers, cx))
.group(authority_accounts_group(cx))
.group(authority_columns_group(project, cx))
- .group(config_file_group())
+ .group(config_file_group(cx))
}
fn descriptions_group(project: &CurrentProject, headers: &[ColumnItem], cx: &App) -> SettingGroup {
@@ -1185,7 +1320,7 @@ fn descriptions_group(project: &CurrentProject, headers: &[ColumnItem], cx: &App
let session_rows = cx
.try_global::()
.map(|pickers| &pickers.description_rows);
- let mut group = SettingGroup::new().title("Descriptions").description(
+ let mut group = divided_group(cx).title("Descriptions").description(
"Explain what each column means. Descriptions appear in the grid and agent context.",
);
let mut missing = Vec::new();
@@ -1294,8 +1429,8 @@ fn description_picker(options: Vec, window: &mut Window, cx: &mut Ap
///
/// Unchecking falls back to `Text`, the type that assumes least. It is why `Text`'s own picker
/// cannot be emptied: a column always has a type, and that is the one it has when nothing is said.
-fn data_types_group(headers: Vec) -> SettingGroup {
- let mut group = SettingGroup::new().title("Data types").description(
+fn data_types_group(headers: Vec, cx: &App) -> SettingGroup {
+ let mut group = divided_group(cx).title("Data types").description(
"What a column holds. Checks key off this: dates are validated as EDTF, filenames are \
resolved against the files folder, and only text is spell-checked.",
);
@@ -1336,7 +1471,7 @@ fn declared_type(name: &str, cx: &App) -> columns::ColumnType {
fn project_page(cx: &App) -> SettingPage {
let Some(project) = cx.try_global::() else {
return SettingPage::new("Project").group(
- SettingGroup::new()
+ divided_group(cx)
.title("Files")
.description("Open a project to see where it looks for files."),
);
@@ -1349,7 +1484,7 @@ fn project_page(cx: &App) -> SettingPage {
project.file.display()
)))
.group(
- SettingGroup::new()
+ divided_group(cx)
.title("Files")
.item(settings::path_picker_item(
settings::project::FILES_FOLDER_KEY,
@@ -1376,8 +1511,8 @@ fn project_page(cx: &App) -> SettingPage {
/// Writing everything on this page back out as the `column_config.csv` the wizard reads, so the
/// next collection starts configured instead of starting again.
-fn config_file_group() -> SettingGroup {
- SettingGroup::new().title("Config file").item(
+fn config_file_group(cx: &App) -> SettingGroup {
+ divided_group(cx).title("Config file").item(
SettingItem::new(
"Export",
SettingField::element(|_opts: &_, _window: &mut _, _cx: &mut _| {
@@ -1406,7 +1541,7 @@ fn authority_accounts_group(cx: &App) -> SettingGroup {
.collect::>()
.join("\n\n");
- SettingGroup::new()
+ divided_group(cx)
.title("Authority accounts")
.description(SharedString::from(described))
.item(
@@ -1439,7 +1574,7 @@ fn authority_columns_group(project: &CurrentProject, cx: &App) -> SettingGroup {
// Read once for the whole page — see the note in `mapping_group`.
let stored = columns::load(cx);
- let mut group = SettingGroup::new().title("Checked against").description(
+ let mut group = divided_group(cx).title("Checked against").description(
"A column is checked against one list. Values are checked over the network, so a wrong \
one is flagged once the answer arrives rather than as you type.",
);
diff --git a/crates/app/src/export.rs b/crates/app/src/export.rs
index bb7b01b2..d120bfc7 100644
--- a/crates/app/src/export.rs
+++ b/crates/app/src/export.rs
@@ -5,6 +5,7 @@
//! action, the save dialog, and the CSL field-mapping picker.
use std::cell::RefCell;
+use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::rc::Rc;
@@ -42,6 +43,16 @@ pub struct Export {
pub format: ExportFormat,
}
+#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
+#[action(namespace = this_app)]
+#[serde(deny_unknown_fields)]
+pub struct PluginExport {
+ pub plugin: String,
+ pub export: String,
+}
+
+const MAX_PLUGIN_EXPORT_BYTES: usize = 64 * 1024 * 1024;
+
/// Menu order. Each entry is the label and the filename the save dialog offers; the Sheets target
/// never touches disk, so it has no name to suggest.
pub const EXPORT_FORMATS: [(ExportFormat, &str, Option<&str>); 6] = [
@@ -71,6 +82,104 @@ fn grid(cx: &App) -> Option<(Vec, Vec>)> {
Some((headers, rows))
}
+/// Ask a plugin for JSON from a fixed live-table snapshot, then let the host save it.
+///
+/// Lua receives no path or file handle. Canceling the save dialog discards the snapshot before the
+/// plugin runs, and dropping qrate cancels the host task.
+pub fn run_plugin(action: &PluginExport, cx: &mut App) {
+ let (Some(project), Some((headers, rows)), Some(plugin)) = (
+ cx.try_global::(),
+ grid(cx),
+ plugin_host::exporter(&action.plugin, cx),
+ ) else {
+ log::warn!("plugin export was asked for with no project or plugin available");
+ return;
+ };
+ let Some(spec) = plugin
+ .exports()
+ .iter()
+ .find(|spec| spec.id.as_ref() == action.export)
+ .cloned()
+ else {
+ log::warn!(
+ "{} has no declared export {:?}",
+ action.plugin,
+ action.export
+ );
+ return;
+ };
+ let columns = headers
+ .iter()
+ .map(|name| {
+ let data_type = project
+ .data
+ .columns
+ .iter()
+ .find(|column| &column.name == name)
+ .map_or_else(|| "Text".to_string(), |column| column.data_type.clone());
+ let column = settings::columns::get(name, cx);
+ plugin_api::ExportColumn {
+ name: name.clone().into(),
+ data_type: data_type.into(),
+ settings: column
+ .plugins
+ .get(&action.plugin)
+ .cloned()
+ .unwrap_or(serde_json::Value::Null),
+ }
+ })
+ .collect();
+ let snapshot = plugin_api::ExportSnapshot {
+ title: project.display_name().into(),
+ columns,
+ rows: rows
+ .into_iter()
+ .map(|row| row.into_iter().map(SharedString::from).collect())
+ .collect(),
+ };
+ let directory = project
+ .file
+ .parent()
+ .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
+ let receiver = cx.prompt_for_new_path(&directory, Some(spec.suggested_name.as_ref()));
+ let export_id = action.export.clone();
+
+ cx.spawn(async move |cx| {
+ let Ok(Ok(Some(path))) = receiver.await else {
+ return;
+ };
+ let result = cx
+ .background_spawn(async move {
+ let value = plugin
+ .export(&export_id, &snapshot)
+ .map_err(anyhow::Error::msg)?;
+ let mut bytes = serde_json::to_vec_pretty(&value)?;
+ bytes.push(b'\n');
+ anyhow::ensure!(
+ bytes.len() <= MAX_PLUGIN_EXPORT_BYTES,
+ "plugin export exceeds the {} MiB output limit",
+ MAX_PLUGIN_EXPORT_BYTES / 1024 / 1024
+ );
+ let parent = path
+ .parent()
+ .ok_or_else(|| anyhow::anyhow!("export path has no parent"))?;
+ let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
+ temporary.write_all(&bytes)?;
+ temporary.as_file_mut().sync_all()?;
+ temporary
+ .persist(&path)
+ .map_err(|error| error.error)
+ .map(|_| ())?;
+ Ok::<(), anyhow::Error>(())
+ })
+ .await;
+ if let Err(error) = result {
+ log::error!("plugin export failed: {error:#}");
+ }
+ })
+ .detach();
+}
+
pub fn run(format: ExportFormat, window: &mut Window, cx: &mut App) {
if is_google(format) && !settings::google_enabled(cx) {
log::info!("Google Sheets export requested while the integration is disabled");
@@ -464,10 +573,7 @@ async fn resolve_token(
/// answer cannot arrive before we are listening.
async fn choose_sheet(access_token: &str, cx: &mut gpui::AsyncApp) -> Option {
log::info!("opening the Google spreadsheet chooser");
- let picker = data_exchange::google::begin_picker(
- data_exchange::google::DEFAULT_PICKER_PAGE,
- access_token,
- );
+ let picker = data_exchange::google::begin_picker(&crate::site::url("/picker"), access_token);
let picker = match picker {
Ok(picker) => picker,
Err(err) => {
diff --git a/crates/app/src/google.rs b/crates/app/src/google.rs
index 0bb39cf2..ec00a614 100644
--- a/crates/app/src/google.rs
+++ b/crates/app/src/google.rs
@@ -6,7 +6,7 @@
use std::time::{SystemTime, UNIX_EPOCH};
-use data_exchange::google::{ClientCreds, DEFAULT_CONFIG_ENDPOINT};
+use data_exchange::google::ClientCreds;
use gpui::App;
use settings::{AppSettings, GOOGLE_CONFIG_ENDPOINT_KEY};
@@ -57,7 +57,7 @@ pub fn stored(cx: &App) -> Stored {
client_secret: text(CLIENT_SECRET_KEY),
}),
endpoint: text(GOOGLE_CONFIG_ENDPOINT_KEY)
- .unwrap_or_else(|| DEFAULT_CONFIG_ENDPOINT.to_string()),
+ .unwrap_or_else(|| crate::site::url("/oauth/config")),
etag: text(ETAG_KEY),
stale: now().saturating_sub(checked_at) > CHECK_INTERVAL,
}
diff --git a/crates/app/src/instance_handoff.rs b/crates/app/src/instance_handoff.rs
new file mode 100644
index 00000000..d109a2a1
--- /dev/null
+++ b/crates/app/src/instance_handoff.rs
@@ -0,0 +1,86 @@
+//! Per-user handoff for browser links that launch a second qrate process.
+
+use std::fs;
+use std::io::Write as _;
+use std::path::PathBuf;
+use std::time::{Duration, SystemTime};
+
+const INSTANCE_NAME: &str = "io.github.devnull03.qrate.plugin-links";
+
+pub fn start(link: Option<&str>, sender: async_channel::Sender) -> bool {
+ let Ok(instance) = single_instance::SingleInstance::new(INSTANCE_NAME) else {
+ log::warn!("could not initialize plugin-link process handoff");
+ return true;
+ };
+ if !instance.is_single() {
+ if let Some(link) = link {
+ log::info!("handing plugin install link to the running qrate instance");
+ if let Err(error) = send(link) {
+ log::error!("could not hand plugin link to the running qrate process: {error}");
+ }
+ return false;
+ }
+ return true;
+ }
+
+ let Some(inbox) = inbox() else {
+ return true;
+ };
+ std::thread::Builder::new()
+ .name("plugin-link-handoff".to_string())
+ .spawn(move || watch(inbox, sender, instance))
+ .map(|_| log::debug!("plugin install link handoff is listening"))
+ .unwrap_or_else(|error| log::warn!("could not start plugin link handoff: {error}"));
+ true
+}
+
+fn inbox() -> Option {
+ settings::data_dir().map(|data| data.join("plugin-link-inbox"))
+}
+
+fn send(link: &str) -> std::io::Result<()> {
+ let inbox = inbox().ok_or_else(|| std::io::Error::other("application data is unavailable"))?;
+ fs::create_dir_all(&inbox)?;
+ let mut temporary = tempfile::NamedTempFile::new_in(&inbox)?;
+ temporary.write_all(link.as_bytes())?;
+ temporary.as_file_mut().sync_all()?;
+ let path = temporary.into_temp_path().keep()?;
+ fs::rename(&path, path.with_extension("link"))
+}
+
+fn watch(
+ inbox: PathBuf,
+ sender: async_channel::Sender,
+ _instance: single_instance::SingleInstance,
+) {
+ if let Err(error) = fs::create_dir_all(&inbox) {
+ log::warn!("could not create plugin-link handoff folder: {error}");
+ return;
+ }
+ loop {
+ if let Ok(entries) = fs::read_dir(&inbox) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().is_none_or(|extension| extension != "link") {
+ continue;
+ }
+ let recent = entry
+ .metadata()
+ .and_then(|metadata| metadata.modified())
+ .ok()
+ .and_then(|modified| SystemTime::now().duration_since(modified).ok())
+ .is_some_and(|age| age <= Duration::from_secs(300));
+ let link = recent
+ .then(|| fs::read_to_string(&path).ok())
+ .flatten()
+ .filter(|link| link.len() <= 4096);
+ let _ = fs::remove_file(path);
+ if let Some(link) = link {
+ log::info!("received plugin install link from a second qrate process");
+ let _ = sender.send_blocking(link);
+ }
+ }
+ }
+ std::thread::sleep(Duration::from_millis(250));
+ }
+}
diff --git a/crates/app/src/logging.rs b/crates/app/src/logging.rs
index 0879eb90..80d156ed 100644
--- a/crates/app/src/logging.rs
+++ b/crates/app/src/logging.rs
@@ -28,11 +28,6 @@ pub enum FeedbackKind {
Ux,
}
-#[cfg(debug_assertions)]
-const FEEDBACK_URL: &str = "http://localhost:4321/feedback";
-#[cfg(not(debug_assertions))]
-const FEEDBACK_URL: &str = "https://qrate.dvnl.work/feedback";
-
/// Where the current session writes. `None` only if the OS has no local data dir, in which case
/// there is nowhere to log and the terminal sink is all there is.
pub fn log_path() -> Option {
@@ -319,8 +314,9 @@ pub fn feedback_url(cx: &App, kind: Option) -> String {
"plugins": plugins.len(),
"plugin_failures": plugins.iter().filter(|(_, error)| error.is_some()).count(),
});
+ let feedback_url = crate::site::url("/feedback");
format!(
- "{FEEDBACK_URL}{kind}#diagnostics={}&logs={}",
+ "{feedback_url}{kind}#diagnostics={}&logs={}",
urlencode(&diagnostics.to_string()),
urlencode(&feedback_log_tail())
)
@@ -364,7 +360,10 @@ mod tests {
)
});
- assert!(bug.starts_with(&format!("{}?type=bug#diagnostics=", super::FEEDBACK_URL)));
+ assert!(bug.starts_with(&format!(
+ "{}?type=bug#diagnostics=",
+ crate::site::url("/feedback")
+ )));
assert!(feature.contains("?type=feature#diagnostics="));
assert!(ux.contains("?type=ui_ux#diagnostics="));
assert!(bug.contains("%22schema%22%3A1"));
diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs
index affddf33..c0c95d5a 100644
--- a/crates/app/src/main.rs
+++ b/crates/app/src/main.rs
@@ -8,7 +8,10 @@ mod app_settings;
mod assets;
mod export;
mod google;
+mod instance_handoff;
mod logging;
+mod plugin_marketplace;
+mod site;
mod status_items;
mod theming;
mod title_items;
@@ -285,6 +288,11 @@ impl Render for App {
.on_action(cx.listener(|_, action: &export::Export, window, cx| {
export::run(action.format, window, cx)
}))
+ .on_action(
+ cx.listener(|_, action: &export::PluginExport, _, cx| {
+ export::run_plugin(action, cx)
+ }),
+ )
.child(
v_flex()
.size_full()
@@ -457,10 +465,32 @@ pub(crate) fn restart_for_update(_: &ClickEvent, window: &mut Window, cx: &mut g
fn main() {
// First, so failures in GPUI platform construction and startup still reach the log file.
logging::init();
+ log::info!("site origin: {}", site::url("/"));
+ let initial_link = std::env::args()
+ .find(|argument| argument.starts_with("qrate://"))
+ .filter(|link| {
+ plugin_package::parse_install_link(link)
+ .inspect_err(|error| log::warn!("ignored invalid plugin install link: {error:#}"))
+ .is_ok()
+ });
+ if initial_link.is_some() {
+ log::info!("received plugin install link at startup");
+ }
+ let (url_sender, url_receiver) = async_channel::unbounded();
+ if !instance_handoff::start(initial_link.as_deref(), url_sender.clone()) {
+ return;
+ }
let app = gpui_platform::application().with_assets(assets::Assets);
+ app.on_open_urls(move |urls| {
+ for url in urls {
+ log::info!("received plugin install link from the operating system");
+ 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();
@@ -508,9 +538,14 @@ fn main() {
cx.on_action(|_: &ReloadPlugins, cx| {
plugin_host::reload(cx);
+ app_menus::install(cx);
table::revalidate_now(cx);
});
cx.on_action(|_: &OpenPluginsFolder, _| plugin_host::open_plugins_folder());
+ cx.on_action(|_: &app_menus::DiscoverPlugins, cx| plugin_marketplace::open_catalog(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)));
@@ -560,6 +595,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);
@@ -567,7 +609,24 @@ 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) => {
+ log::info!("opening plugin installation target: {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
new file mode 100644
index 00000000..db3b4c84
--- /dev/null
+++ b/crates/app/src/plugin_marketplace.rs
@@ -0,0 +1,767 @@
+//! Signed plugin discovery and direct GitHub release installation.
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use gpui::prelude::FluentBuilder as _;
+use gpui::*;
+use gpui_component::button::{Button, ButtonVariants as _};
+use gpui_component::collapsible::Collapsible;
+use gpui_component::input::{Input, InputEvent, 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, InstallTarget, PackageInspection, ReleaseStatus,
+};
+use window_wrapper::WindowRegistry;
+
+const MARKETPLACE_WINDOW_KIND: &str = "plugin-marketplace";
+struct MarketplaceHandle(WeakEntity);
+impl Global for MarketplaceHandle {}
+
+enum CatalogState {
+ Loading,
+ Ready(Vec),
+ Error(Arc),
+}
+
+enum DirectState {
+ Idle,
+ Loading,
+ Review(Box),
+ Error(Arc),
+}
+
+struct DirectReview {
+ release: DirectRelease,
+ inspection: PackageInspection,
+ archive: PathBuf,
+}
+
+impl Drop for DirectReview {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_file(&self.archive);
+ }
+}
+
+pub(crate) struct MarketplaceWindow {
+ catalog: CatalogState,
+ direct: DirectState,
+ input: Entity,
+ status: Option>,
+ requested_id: Option,
+ direct_expanded: bool,
+ direct_request: u64,
+ embedded: bool,
+ _input_subscription: Subscription,
+}
+
+impl MarketplaceWindow {
+ pub(crate) fn new(
+ direct: bool,
+ target: Option,
+ embedded: bool,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ if !embedded {
+ window.set_window_title("qrate plugins");
+ }
+ let input =
+ cx.new(|cx| InputState::new(window, cx).placeholder("https://github.com/owner/plugin"));
+ let input_subscription = cx.subscribe(&input, |this, input, event: &InputEvent, cx| {
+ if matches!(event, InputEvent::Change) {
+ this.status = None;
+ this.resolve_direct(input.read(cx).value().to_string(), cx);
+ }
+ });
+ 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,
+ requested_id,
+ direct_expanded: direct || direct_source.is_some(),
+ direct_request: 0,
+ embedded,
+ _input_subscription: input_subscription,
+ };
+ if this.requested_id.is_some() {
+ this.refresh(cx);
+ }
+ if let Some(source) = direct_source {
+ this.input
+ .update(cx, |input, cx| input.set_value(source.clone(), window, cx));
+ this.resolve_direct(source, cx);
+ } else if direct {
+ this.input.focus_handle(cx).focus(window, cx);
+ }
+ this
+ }
+
+ fn apply_target(&mut self, target: InstallTarget, window: &mut Window, cx: &mut Context) {
+ self.status = None;
+ match target {
+ InstallTarget::Registry(id) => {
+ self.requested_id = Some(id);
+ self.refresh(cx);
+ }
+ InstallTarget::Github(source) => {
+ self.requested_id = None;
+ self.direct_expanded = true;
+ self.direct = DirectState::Idle;
+ self.input
+ .update(cx, |input, cx| input.set_value(source.clone(), window, cx));
+ self.resolve_direct(source, cx);
+ }
+ }
+ cx.notify();
+ }
+
+ 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());
+ log::info!("fetching signed plugin catalog");
+ 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; set \
+ QRATE_PLUGIN_CATALOG_PUBLIC_KEY for local development"
+ )
+ })?;
+ let key = plugin_package::public_key(&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(
+ &crate::site::url("/plugins/catalog.json"),
+ &key,
+ &data.join("plugin-catalog"),
+ )
+ })
+ .await;
+ this.update(cx, |this, cx| {
+ this.catalog = match result {
+ Ok(catalog) => {
+ log::info!(
+ "signed plugin catalog loaded with {} listings",
+ catalog.plugins.len()
+ );
+ CatalogState::Ready(catalog.plugins)
+ }
+ Err(error) => {
+ log::warn!("signed plugin catalog is unavailable: {error:#}");
+ CatalogState::Error(format!("{error:#}").into())
+ }
+ };
+ cx.notify();
+ })
+ .ok();
+ })
+ .detach();
+ }
+
+ fn resolve_direct(&mut self, source: String, cx: &mut Context) {
+ self.direct_request = self.direct_request.wrapping_add(1);
+ let request = self.direct_request;
+ let source = source.trim().to_string();
+ if source.trim().is_empty() {
+ self.direct = DirectState::Idle;
+ cx.notify();
+ return;
+ }
+ if let Err(error) = plugin_package::validate_github_source(&source) {
+ log::debug!("direct plugin source is not reviewable: {error:#}");
+ self.direct = DirectState::Error(format!("{error:#}").into());
+ cx.notify();
+ return;
+ }
+ log::info!("checking direct plugin release from {source}");
+ self.direct = DirectState::Loading;
+ cx.notify();
+ cx.spawn(async move |this, cx| {
+ cx.background_executor()
+ .timer(Duration::from_millis(300))
+ .await;
+ if !this
+ .update(cx, |this, _| this.direct_request == request)
+ .unwrap_or(false)
+ {
+ return;
+ }
+ 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(DirectReview {
+ release,
+ inspection,
+ archive,
+ })
+ })
+ .await;
+ this.update(cx, |this, cx| {
+ if this.direct_request != request {
+ return;
+ }
+ this.direct = match result {
+ Ok(review) => {
+ log::info!(
+ "direct plugin package checked: {} {} ({} bytes, SHA-256 {})",
+ review.inspection.manifest.id,
+ review.inspection.manifest.version,
+ review.inspection.bytes,
+ review.inspection.sha256
+ );
+ DirectState::Review(Box::new(review))
+ }
+ Err(error) => {
+ log::warn!("direct plugin package check failed: {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 {
+ log::warn!("refused to install revoked plugin release {}", plugin.id);
+ self.status = Some("This release is revoked and cannot be installed".into());
+ 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 {
+ format!("Requests: {}", plugin.current.permissions.join(", "))
+ };
+ let answer = window.prompt(
+ PromptLevel::Warning,
+ &format!("Install {} {}?", plugin.name, plugin.current.version),
+ Some(&format!(
+ "Official catalog · {} · {permissions}.",
+ plugin.publisher,
+ )),
+ &["Install", "Cancel"],
+ cx,
+ );
+ let name = plugin.name.clone();
+ cx.spawn_in(window, 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 {
+ log::info!(
+ "installing official plugin {} {}",
+ plugin.id,
+ plugin.current.version
+ );
+ 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_checked(
+ temp.path(),
+ &plugins,
+ &receipts,
+ InstallSource::OfficialCatalog,
+ Some((&plugin, &plugin.current)),
+ |root, manifest| {
+ plugin_host::validate_package(root, &manifest.id)
+ .map_err(anyhow::Error::msg)
+ },
+ )
+ })
+ .await;
+ this.update_in(cx, |this, window, cx| {
+ match result {
+ Ok(receipt) => {
+ log::info!(
+ "official plugin installed: {} {} (SHA-256 {})",
+ receipt.id,
+ receipt.version,
+ receipt.sha256
+ );
+ this.installed(&receipt, &name, already_managed, window, cx);
+ }
+ Err(error) => {
+ log::error!("official plugin installation failed: {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(review) = &self.direct else {
+ return;
+ };
+ let DirectReview {
+ release,
+ inspection,
+ archive,
+ } = review.as_ref();
+ 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 already_managed = installed_receipt(&inspection.manifest.id).is_some();
+ let name = inspection.manifest.name.clone();
+ 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_in(window, async move |this, cx| {
+ if answer.await.unwrap_or(1) != 0 {
+ return;
+ }
+ let result = cx
+ .background_spawn(async move {
+ log::info!(
+ "installing direct plugin {} {} from {}",
+ inspection.manifest.id,
+ inspection.manifest.version,
+ release.repository
+ );
+ 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_direct_archive_checked(
+ &archive,
+ &plugins,
+ &receipts,
+ &release,
+ |root, manifest| {
+ plugin_host::validate_package(root, &manifest.id)
+ .map_err(anyhow::Error::msg)
+ },
+ );
+ let _ = std::fs::remove_file(archive);
+ result
+ })
+ .await;
+ this.update_in(cx, |this, window, cx| {
+ match result {
+ Ok(receipt) => {
+ log::info!(
+ "direct plugin installed: {} {} (SHA-256 {})",
+ receipt.id,
+ receipt.version,
+ receipt.sha256
+ );
+ this.direct = DirectState::Idle;
+ this.installed(&receipt, &name, already_managed, window, cx);
+ }
+ Err(error) => {
+ log::error!("direct plugin installation failed: {error:#}");
+ this.status = Some(format!("Installation failed: {error:#}").into())
+ }
+ }
+ cx.notify();
+ })
+ .ok();
+ })
+ .detach();
+ }
+
+ fn installed(
+ &mut self,
+ receipt: &plugin_package::InstallReceipt,
+ name: &str,
+ already_managed: bool,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ // Removing a package switches it off, so a reinstall has to switch it back on.
+ if !already_managed {
+ settings::plugins::set_enabled(&receipt.id, true, cx);
+ }
+ plugin_host::reload(cx);
+ if self.embedded {
+ self.status = Some(format!("{name} {} installed and enabled.", receipt.version).into());
+ } else {
+ window.remove_window();
+ }
+ }
+}
+
+impl Render for MarketplaceWindow {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let muted = cx.theme().muted_foreground;
+ let permissions = |permissions: &[String]| {
+ if permissions.is_empty() {
+ "No optional permissions".to_string()
+ } else {
+ format!("Requests {}", permissions.join(", "))
+ }
+ };
+ let requested = match &self.catalog {
+ CatalogState::Ready(plugins) => self
+ .requested_id
+ .as_ref()
+ .and_then(|id| plugins.iter().find(|plugin| &plugin.id == id))
+ .cloned(),
+ _ => None,
+ };
+
+ let catalog = match (&self.catalog, &requested) {
+ (_, Some(plugin)) => v_flex()
+ .w_full()
+ .min_w_0()
+ .gap_1()
+ .child(
+ Label::new(format!("{} {}", plugin.name, plugin.current.version))
+ .w_full()
+ .font_semibold(),
+ )
+ .child(Label::new(plugin.summary.clone()).w_full().text_sm())
+ .child(
+ Label::new(format!(
+ "{} · {} · API {} · {}",
+ plugin.publisher,
+ plugin.license,
+ plugin.current.api_version,
+ permissions(&plugin.current.permissions)
+ ))
+ .w_full()
+ .text_sm()
+ .text_color(muted),
+ )
+ .child(
+ Label::new(plugin.repository.clone())
+ .w_full()
+ .text_xs()
+ .text_color(muted),
+ )
+ .into_any_element(),
+ (CatalogState::Loading, _) => Label::new("Loading the signed catalog…")
+ .text_sm()
+ .into_any_element(),
+ (CatalogState::Error(error), _) => Label::new(format!("Catalog unavailable: {error}"))
+ .text_sm()
+ .text_color(muted)
+ .into_any_element(),
+ (CatalogState::Ready(_), None) => {
+ Label::new("That plugin is not in the current signed catalog.")
+ .text_sm()
+ .text_color(muted)
+ .into_any_element()
+ }
+ };
+
+ let direct = match &self.direct {
+ DirectState::Idle => Label::new(
+ "Paste a public GitHub repository or release URL. qrate checks valid links automatically.",
+ )
+ .text_sm()
+ .text_color(muted)
+ .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(review) => {
+ let manifest = &review.inspection.manifest;
+ v_flex()
+ .w_full()
+ .min_w_0()
+ .gap_1()
+ .child(
+ Label::new(format!("{} {}", manifest.name, manifest.version))
+ .w_full()
+ .font_semibold(),
+ )
+ .child(
+ Label::new(format!(
+ "API {} · {} · {} bytes · {}",
+ manifest.api_version,
+ manifest.license,
+ review.inspection.bytes,
+ permissions(&manifest.permissions)
+ ))
+ .w_full()
+ .text_sm()
+ .text_color(muted),
+ )
+ .child(
+ Label::new(review.release.repository.clone())
+ .w_full()
+ .text_xs()
+ .text_color(muted),
+ )
+ .child(
+ Label::new(format!("SHA-256 {}", review.inspection.sha256))
+ .w_full()
+ .text_xs()
+ .text_color(muted),
+ )
+ .into_any_element()
+ }
+ };
+
+ let action = if self.requested_id.is_some() {
+ match (&self.catalog, requested) {
+ (_, Some(plugin)) => {
+ let installed = installed_receipt(&plugin.id);
+ let revoked = plugin.current.status == ReleaseStatus::Revoked;
+ let current = installed
+ .as_ref()
+ .is_some_and(|receipt| receipt.version >= plugin.current.version);
+ Some(
+ Button::new("install-official")
+ .primary()
+ .label(if revoked {
+ "Revoked"
+ } else if current {
+ "Installed"
+ } else if installed.is_some() {
+ "Update"
+ } else {
+ "Install"
+ })
+ .disabled(revoked || current)
+ .on_click(cx.listener(move |this, _, window, cx| {
+ this.install_official(plugin.clone(), window, cx);
+ })),
+ )
+ }
+ (CatalogState::Error(_), None) => Some(
+ Button::new("catalog-site")
+ .label("Browse website")
+ .on_click(|_, _, cx| open_catalog(cx)),
+ ),
+ _ => None,
+ }
+ } else if matches!(self.direct, DirectState::Review(_)) {
+ Some(
+ Button::new("install-direct")
+ .primary()
+ .label("Install unlisted plugin")
+ .on_click(cx.listener(|this, _, window, cx| this.install_direct(window, cx))),
+ )
+ } else {
+ None
+ };
+ let (inline_action, footer_action) = if self.embedded {
+ (action, None)
+ } else {
+ (None, action)
+ };
+ let status = self
+ .status
+ .clone()
+ .map(|status| Label::new(status).text_sm().text_color(muted));
+
+ let direct_installer = Collapsible::new()
+ .open(self.direct_expanded)
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Button::new("browse-plugin-catalog")
+ .small()
+ .label("Browse catalog")
+ .on_click(|_, _, cx| open_catalog(cx)),
+ )
+ .child(
+ Button::new("toggle-github-installer")
+ .small()
+ .label(if self.direct_expanded {
+ "Hide GitHub installer"
+ } else {
+ "Install from GitHub…"
+ })
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.direct_expanded = !this.direct_expanded;
+ log::info!(
+ "GitHub plugin installer expanded: {}",
+ this.direct_expanded
+ );
+ if this.direct_expanded {
+ this.input.focus_handle(cx).focus(window, cx);
+ }
+ cx.notify();
+ })),
+ ),
+ )
+ .content(
+ v_flex()
+ .gap_2()
+ .mt_2()
+ .child(Input::new(&self.input).w_full())
+ .child(direct)
+ .children(inline_action.map(|action| h_flex().child(action.small()))),
+ );
+
+ if self.embedded {
+ return v_flex()
+ .gap_2()
+ .child(direct_installer)
+ .children(status)
+ .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()
+ .min_h_0()
+ .overflow_y_scrollbar()
+ .p_4()
+ .gap_3()
+ .map(|body| {
+ if self.requested_id.is_some() {
+ body.child(catalog)
+ } else {
+ body.child(direct_installer)
+ }
+ })
+ .children(status),
+ )
+ .when_some(footer_action, |view, action| {
+ view.child(
+ h_flex()
+ .w_full()
+ .flex_none()
+ .justify_end()
+ .px_4()
+ .py_3()
+ .border_t_1()
+ .border_color(cx.theme().border)
+ .child(action),
+ )
+ })
+ .into_any_element()
+ }
+}
+
+fn installed_receipt(id: &str) -> Option {
+ plugin_host::plugins_dir().and_then(|plugins| {
+ let data = plugins.parent()?;
+ plugin_package::read_receipt(&plugin_package::receipts_dir(data), id)
+ .ok()
+ .flatten()
+ })
+}
+
+pub fn open_catalog(cx: &mut gpui::App) {
+ log::info!("opening plugin catalog in the default browser");
+ cx.open_url(&crate::site::url("/plugins"));
+}
+
+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 let Some(handle) = WindowRegistry::focus_or_clear(MARKETPLACE_WINDOW_KIND, cx) {
+ if let Some(target) = target {
+ handle
+ .update(cx, |_, window, cx| {
+ if let Some(marketplace) = cx
+ .try_global::()
+ .and_then(|handle| handle.0.upgrade())
+ {
+ marketplace.update(cx, |marketplace, cx| {
+ marketplace.apply_target(target, window, cx);
+ });
+ }
+ })
+ .ok();
+ }
+ return;
+ }
+ let win_size = size(px(460.0), px(380.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(400.0), px(300.0))),
+ ..Default::default()
+ };
+ if let Ok(handle) = cx.open_window(options, |window, cx| {
+ let view = cx.new(|cx| MarketplaceWindow::new(direct, target, false, window, cx));
+ cx.set_global(MarketplaceHandle(view.downgrade()));
+ cx.new(|cx| Root::new(view, window, cx))
+ }) {
+ WindowRegistry::register(MARKETPLACE_WINDOW_KIND, handle.into(), cx);
+ }
+}
diff --git a/crates/app/src/site.rs b/crates/app/src/site.rs
new file mode 100644
index 00000000..9f51c8aa
--- /dev/null
+++ b/crates/app/src/site.rs
@@ -0,0 +1,27 @@
+#[cfg(debug_assertions)]
+const DEFAULT_ORIGIN: &str = "http://localhost:4321";
+#[cfg(not(debug_assertions))]
+const DEFAULT_ORIGIN: &str = "https://qrate.dvnl.work";
+
+pub fn url(path: &str) -> String {
+ debug_assert!(path.starts_with('/'));
+ format!(
+ "{}{path}",
+ option_env!("QRATE_SITE_ORIGIN").unwrap_or(DEFAULT_ORIGIN)
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ #[cfg(debug_assertions)]
+ #[test]
+ fn development_builds_use_the_local_site() {
+ assert_eq!(super::url("/plugins"), "http://localhost:4321/plugins");
+ }
+
+ #[cfg(not(debug_assertions))]
+ #[test]
+ fn release_builds_use_the_production_site() {
+ assert_eq!(super::url("/plugins"), "https://qrate.dvnl.work/plugins");
+ }
+}
diff --git a/crates/app/src/title_items/launcher_bar.rs b/crates/app/src/title_items/launcher_bar.rs
index 028bebb5..0216295c 100644
--- a/crates/app/src/title_items/launcher_bar.rs
+++ b/crates/app/src/title_items/launcher_bar.rs
@@ -14,8 +14,8 @@ 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, ManagePlugins, OpenAbout, OpenLogsFolder, OpenPluginsFolder,
+ OpenSettings, REPO_URL, ReloadPlugins, ReportBug, ReportUxIssue, RequestFeature,
};
use crate::title_items::update_notice::UpdateNotice;
@@ -58,8 +58,11 @@ impl Render for LauncherBar {
menu.menu("New Project…", Box::new(NewProject))
.menu("Settings…", Box::new(OpenSettings))
.separator()
- .submenu("Extensions", window, cx, |menu, _, _| {
- menu.menu("Plugins Folder", Box::new(OpenPluginsFolder))
+ .submenu("Plugins", window, cx, |menu, _, _| {
+ menu.menu("Discover Plugins…", Box::new(DiscoverPlugins))
+ .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| {
@@ -72,7 +75,7 @@ impl Render for LauncherBar {
.menu(
"Releases",
Box::new(OpenBrowser {
- url: "https://qrate.dvnl.work/releases".into(),
+ url: crate::site::url("/releases"),
}),
)
.separator()
diff --git a/crates/app/src/update_check.rs b/crates/app/src/update_check.rs
index d3b597fc..cf9553e9 100644
--- a/crates/app/src/update_check.rs
+++ b/crates/app/src/update_check.rs
@@ -8,8 +8,6 @@ use semver::Version;
use settings::AppSettings;
use updater::{InstallKind, Installation, JOB_NAME, ReleaseChannel, StagedUpdate, UpdateJob};
-const BETA_FEED: &str = "https://qrate.dvnl.work/updates/beta.json";
-const STABLE_FEED: &str = "https://qrate.dvnl.work/updates/stable.json";
const POLL_INTERVAL: Duration = Duration::from_secs(60 * 60);
pub fn automatic_updates(cx: &App) -> bool {
@@ -179,13 +177,13 @@ impl AutoUpdater {
let current = Version::parse(env!("CARGO_PKG_VERSION")).expect("package version is SemVer");
let feed = match ReleaseChannel::for_version(¤t) {
- ReleaseChannel::Beta => BETA_FEED,
- ReleaseChannel::Stable => STABLE_FEED,
+ ReleaseChannel::Beta => crate::site::url("/updates/beta.json"),
+ ReleaseChannel::Stable => crate::site::url("/updates/stable.json"),
};
let (tx, rx) = async_channel::unbounded();
cx.background_spawn(async move {
let result = updater::fetch_and_stage(
- feed,
+ &feed,
&installation,
¤t,
|version| {
diff --git a/crates/data-exchange/src/google.rs b/crates/data-exchange/src/google.rs
index 0e5807bd..286fa8e2 100644
--- a/crates/data-exchange/src/google.rs
+++ b/crates/data-exchange/src/google.rs
@@ -26,14 +26,6 @@ const SCOPE: &str = "https://www.googleapis.com/auth/drive.file";
/// confidential anyway (see the module docs on the client secret).
const CONFIG_TOKEN: Option<&str> = option_env!("QRATE_GOOGLE_CONFIG_TOKEN");
-/// Where [`fetch_config`] looks unless the user points it somewhere else. Anyone can run the same
-/// contract on their own infrastructure — see `docs/dev/site-oauth-handoff.md`.
-pub const DEFAULT_CONFIG_ENDPOINT: &str = "https://qrate.dvnl.work/oauth/config";
-
-/// Where the Google Picker page lives. `drive.file` reaches only files the app created or the user
-/// picked *through the Picker*, so this page is the sole route to an already-owned spreadsheet.
-pub const DEFAULT_PICKER_PAGE: &str = "https://qrate.dvnl.work/picker";
-
#[derive(Debug, Error)]
pub enum GoogleError {
#[error("This build of qrate has no Google client ID, so it can't sign in to Google")]
diff --git a/crates/plugin-api/src/lib.rs b/crates/plugin-api/src/lib.rs
index 94848443..5c22055e 100644
--- a/crates/plugin-api/src/lib.rs
+++ b/crates/plugin-api/src/lib.rs
@@ -205,6 +205,38 @@ pub struct CommandContext {
pub argument: Option,
}
+/// One JSON export a plugin contributes to File ▸ Export.
+///
+/// The plugin supplies only labels and a suggested filename. The host owns the save dialog and
+/// never gives the selected path or a file handle to Lua.
+#[derive(Clone, Debug)]
+pub struct ExportSpec {
+ /// Plugin-local identifier passed back to its `export` function.
+ pub id: SharedString,
+ pub label: SharedString,
+ pub suggested_name: SharedString,
+}
+
+/// One column in the immutable snapshot passed to a plugin export.
+#[derive(Clone, Debug)]
+pub struct ExportColumn {
+ pub name: SharedString,
+ pub data_type: SharedString,
+ /// Only the exporting plugin's settings for this column.
+ pub settings: serde_json::Value,
+}
+
+/// A complete, immutable view of the live table.
+///
+/// Rows contain one string per column in the same order as [`Self::columns`]. Construction and size
+/// limits are host-owned; plugins receive values, never qrate table objects.
+#[derive(Clone, Debug)]
+pub struct ExportSnapshot {
+ pub title: SharedString,
+ pub columns: Vec,
+ pub rows: Vec>,
+}
+
/// A plugin's offer of what could go in the cell being typed in, and which cell it was asked for.
///
/// The cell is echoed back from the request rather than tracked here, so the table can drop an
diff --git a/crates/plugin-host/src/lib.rs b/crates/plugin-host/src/lib.rs
index 30eed454..4ba85c58 100644
--- a/crates/plugin-host/src/lib.rs
+++ b/crates/plugin-host/src/lib.rs
@@ -1,14 +1,10 @@
//! Loads Lua plugins from a folder, runs them off the UI thread, and publishes what they
//! contribute to the app's menus, bars, and Settings window.
//!
-//! Neovim's model, not an extension marketplace: a plugin is a `.lua` file or a `/`
-//! directory containing `init.lua`, dropped in by hand, discovered on startup and on demand. There
-//! is no manifest — the table the script returns *is* the descriptor, and it may declare a `name`
-//! to override the one on disk. That name is the plugin's identity, which is what the Problems
-//! panel shows, what its findings are replaced by, and what its settings are stored under, so
-//! renaming a plugin orphans whatever it had already stored. A folder plugin may `require` any
-//! other `.lua` file beside its `init.lua`, and nothing else — the host reads the folder, so the
-//! sandbox never gains a way to name a file itself.
+//! A plugin is a `.lua` file or a `/` directory containing `init.lua`, discovered on
+//! startup and on demand. Managed packages add static `qrate-plugin.json`; the Lua table remains
+//! the runtime descriptor. A folder plugin may `require` sibling `.lua` files, and nothing else —
+//! the host reads the folder, so the sandbox never gains a way to name a file itself.
mod agent_program;
mod plugin;
@@ -17,7 +13,7 @@ pub use agent_program::{
AgentDiagnostic, AgentProgramError, AgentProgramOutput, AgentSnapshot, DEADLINE, MAX_OUTPUT,
MEMORY_LIMIT, run_agent_program, validate_agent_program,
};
-pub use plugin::{Env, LuaPlugin, PERMISSION_NET, Writes};
+pub use plugin::{Env, LuaPlugin, PERMISSION_NET, PackageDescriptor, Writes};
// So the Settings window can render a plugin's knobs without depending on `plugin-api` directly.
pub use plugin_api::{SettingKind, SettingScope, SettingSpec};
@@ -55,7 +51,7 @@ struct Plugins {
/// whole staleness scheme — the same trick `TablePanel`'s autosave task uses.
run: Option>,
/// Found on disk but switched off, so the Settings page can offer them back.
- off: Vec,
+ off: Vec<(SharedString, SharedString)>,
/// Re-hands the scoped settings whenever either store changes.
///
/// Without this, a plugin only ever saw the settings that existed when it loaded — and plugins
@@ -156,12 +152,16 @@ pub fn reload(cx: &mut App) {
// are keyed by the name on disk, which is also the only name a user has to go on before a
// plugin has successfully loaded.
let (mut loaded, mut off) = (Vec::new(), Vec::new());
- for (id, source, modules) in discover() {
+ for (id, source, modules, package) in discover() {
let state = settings::plugins::state(&id, cx);
// Switched-off plugins are remembered by name and nothing else: the Settings page has to be
// able to offer one back, and a VM is exactly what must not be built to do that.
if !state.enabled {
- off.push(SharedString::from(id));
+ let name = match &package {
+ Ok(Some(package)) => package.name.clone(),
+ _ => id.clone(),
+ };
+ off.push((SharedString::from(id), SharedString::from(name)));
continue;
}
let env = Env {
@@ -169,7 +169,9 @@ pub fn reload(cx: &mut App) {
granted: state.granted,
storage: read_storage(&id),
};
- loaded.push(Arc::new(LuaPlugin::load(&id, &source, env)));
+ loaded.push(Arc::new(LuaPlugin::load_packaged(
+ &id, &source, env, package,
+ )));
}
let contributions = loaded
@@ -242,7 +244,7 @@ pub fn status(cx: &App) -> Vec<(SharedString, Option)> {
plugins
.off
.iter()
- .map(|id| (id.clone(), Some("switched off".to_string()))),
+ .map(|(_, name)| (name.clone(), Some("switched off".to_string()))),
)
.collect()
})
@@ -254,6 +256,8 @@ pub struct Listing {
/// descriptor's `name`: a plugin that fails to load has no descriptor, and switching a broken
/// plugin off has to work.
pub id: SharedString,
+ /// The package's `name`, else the descriptor's, else `id`.
+ pub name: SharedString,
pub description: Option,
pub load_error: Option,
/// What it asked to be allowed to do. Whether the user agreed is read live from
@@ -271,12 +275,14 @@ pub fn listing(cx: &App) -> Vec {
.iter()
.map(|plugin| Listing {
id: plugin.id(),
+ name: plugin.name(),
description: plugin.description(),
load_error: plugin.load_error().map(str::to_string),
permissions: plugin.permissions().to_vec(),
})
- .chain(plugins.off.iter().map(|id| Listing {
+ .chain(plugins.off.iter().map(|(id, name)| Listing {
id: id.clone(),
+ name: name.clone(),
description: None,
load_error: None,
// Unknown until it loads — nothing has read its descriptor. Turning it back on is the
@@ -490,6 +496,35 @@ pub fn setting_specs(cx: &App) -> Vec<(SharedString, Option, Vec Vec<(SharedString, plugin_api::ExportSpec)> {
+ cx.try_global::().map_or(Vec::new(), |plugins| {
+ plugins
+ .loaded
+ .iter()
+ .flat_map(|plugin| {
+ let name = plugin.name();
+ plugin
+ .exports()
+ .iter()
+ .cloned()
+ .map(move |spec| (name.clone(), spec))
+ })
+ .collect()
+ })
+}
+
+/// Resolve a loaded plugin before its export moves to the background executor.
+pub fn exporter(plugin: &str, cx: &App) -> Option> {
+ cx.try_global::().and_then(|plugins| {
+ plugins
+ .loaded
+ .iter()
+ .find(|candidate| candidate.name().as_ref() == plugin)
+ .cloned()
+ })
+}
+
/// One declared setting's stored value, [`Json::Null`] if the plugin has never stored it.
pub fn setting_value(plugin: &str, spec: &SettingSpec, cx: &App) -> Json {
let object = match spec.scope {
@@ -624,8 +659,18 @@ fn invoke(plugin: &SharedString, command: &SharedString, ctx: &CommandContext, c
.detach();
}
-/// A plugin as it was found on disk: its id, its entry source, and the modules beside it.
-type Discovered = (String, String, Vec<(String, String)>);
+/// A plugin as it was found on disk: its id, entry source, sibling modules, and package contract.
+type Discovered = (
+ String,
+ String,
+ Vec<(String, String)>,
+ Result, String>,
+);
+
+struct PackageOnDisk {
+ descriptor: PackageDescriptor,
+ entry: String,
+}
/// Every plugin under the searched roots. A plugin is either a
/// `.lua` file or a `/init.lua` folder — the folder form for anything that outgrows one
@@ -647,7 +692,7 @@ fn discover() -> Vec {
};
for entry in entries.flatten() {
let path = entry.path();
- let (id, file) = if path.is_dir() {
+ let (id, default_file) = if path.is_dir() {
(entry.file_name(), path.join("init.lua"))
} else if path.extension().is_some_and(|ext| ext == "lua") {
match path.file_stem() {
@@ -660,11 +705,25 @@ fn discover() -> Vec {
// First form wins, so converting `foo.lua` into `foo/` and forgetting to delete the
// file leaves one plugin rather than two fighting over one settings bucket.
let id = id.to_string_lossy().into_owned();
- if found.iter().any(|(seen, _, _)| seen == &id) {
+ if found.iter().any(|(seen, _, _, _)| seen == &id) {
continue;
}
+ let package = if path.is_dir() {
+ package_on_disk(&path, &id)
+ } else {
+ Ok(None)
+ };
+ let file = package
+ .as_ref()
+ .ok()
+ .and_then(Option::as_ref)
+ .map_or(default_file, |package| path.join(&package.entry));
+ let descriptor = package.map(|package| package.map(|package| package.descriptor));
match fs::read_to_string(&file) {
- Ok(source) => found.push((id, source, modules(&path))),
+ Ok(source) => {
+ let entry_name = file.file_stem().and_then(|name| name.to_str());
+ found.push((id, source, modules(&path, entry_name), descriptor));
+ }
// A `/` with no `init.lua` is the usual cause, and vanishing without a word
// is what makes it hard to spot.
Err(err) => log::warn!("skipping plugin {id}: {} — {err}", file.display()),
@@ -674,9 +733,83 @@ fn discover() -> Vec {
found
}
+/// Loads an extracted managed package before the installer replaces an existing plugin.
+///
+/// This checks the Lua descriptor against the static package contract without running validation
+/// hooks or granting optional permissions.
+pub fn validate_package(root: &Path, id: &str) -> Result<(), String> {
+ let package = package_on_disk(root, id)?
+ .ok_or_else(|| "plugin package has no qrate-plugin.json".to_string())?;
+ let entry = root.join(&package.entry);
+ let source = fs::read_to_string(&entry)
+ .map_err(|error| format!("could not read package entry {}: {error}", entry.display()))?;
+ let entry_name = entry.file_stem().and_then(|name| name.to_str());
+ let env = Env {
+ modules: modules(root, entry_name),
+ ..Env::default()
+ };
+ let plugin = LuaPlugin::load_packaged(id, &source, env, Ok(Some(package.descriptor)));
+ match plugin.load_error() {
+ Some(error) => Err(error.to_string()),
+ None => Ok(()),
+ }
+}
+
+fn package_on_disk(root: &Path, folder_id: &str) -> Result, String> {
+ let bytes = match fs::read(root.join("qrate-plugin.json")) {
+ Ok(bytes) => bytes,
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
+ Err(error) => return Err(format!("could not read qrate-plugin.json: {error}")),
+ };
+ let manifest: Json = serde_json::from_slice(&bytes)
+ .map_err(|error| format!("invalid qrate-plugin.json: {error}"))?;
+ let id = manifest
+ .get("id")
+ .and_then(Json::as_str)
+ .ok_or_else(|| "qrate-plugin.json has no ID".to_string())?;
+ if id != folder_id {
+ return Err("package ID does not match its installation folder".to_string());
+ }
+ let name = manifest
+ .get("name")
+ .and_then(Json::as_str)
+ .ok_or_else(|| "qrate-plugin.json has no name".to_string())?;
+ let entry = manifest
+ .get("entry")
+ .and_then(Json::as_str)
+ .ok_or_else(|| "qrate-plugin.json has no entry".to_string())?;
+ if Path::new(entry).file_name().and_then(|name| name.to_str()) != Some(entry) {
+ return Err("package entry must be a root file".to_string());
+ }
+ let api_version = manifest
+ .get("api_version")
+ .and_then(Json::as_u64)
+ .ok_or_else(|| "qrate-plugin.json has no API version".to_string())?;
+ let permissions = manifest
+ .get("permissions")
+ .and_then(Json::as_array)
+ .ok_or_else(|| "qrate-plugin.json has no permissions".to_string())?
+ .iter()
+ .map(|permission| {
+ permission
+ .as_str()
+ .map(str::to_string)
+ .ok_or_else(|| "package permission is not a string".to_string())
+ })
+ .collect::, _>>()?;
+ Ok(Some(PackageOnDisk {
+ descriptor: PackageDescriptor {
+ name: name.to_string(),
+ api_version,
+ permissions,
+ },
+ entry: entry.to_string(),
+ }))
+}
+
/// Every other `.lua` file beside a folder plugin's `init.lua`, as `(name, source)` — what its
/// `require` can reach. A single-file plugin has none, and neither form searches below one level.
-fn modules(dir: &Path) -> Vec<(String, String)> {
+fn modules(dir: &Path, entry_name: Option<&str>) -> Vec<(String, String)> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
@@ -685,7 +818,8 @@ fn modules(dir: &Path) -> Vec<(String, String)> {
.filter_map(|entry| {
let path = entry.path();
let name = path.file_stem()?.to_string_lossy().into_owned();
- if name == "init" || path.extension().is_none_or(|ext| ext != "lua") {
+ if entry_name == Some(name.as_str()) || path.extension().is_none_or(|ext| ext != "lua")
+ {
return None;
}
fs::read_to_string(&path)
@@ -697,12 +831,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-host/src/plugin.rs b/crates/plugin-host/src/plugin.rs
index c0e067ce..9f53e8f1 100644
--- a/crates/plugin-host/src/plugin.rs
+++ b/crates/plugin-host/src/plugin.rs
@@ -13,7 +13,8 @@
//! an `Arc` and run on the background executor — see `validate_async` in the parent module.
use std::cell::Cell;
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
+use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
@@ -21,8 +22,8 @@ use diagnostics::{ColumnInfo, ColumnValidator, Severity};
use gpui::SharedString;
use mlua::{Function, Lua, LuaOptions, LuaSerdeExt as _, SerializeOptions, StdLib, Table, VmState};
use plugin_api::{
- Bar, BarAction, BarItem, ColumnMapSpec, CommandContext, MenuItem, MenuTarget, SettingKind,
- SettingScope, SettingSpec, Side,
+ Bar, BarAction, BarItem, ColumnMapSpec, CommandContext, ExportSnapshot, ExportSpec, MenuItem,
+ MenuTarget, SettingKind, SettingScope, SettingSpec, Side,
};
use serde_json::Value as Json;
@@ -38,6 +39,10 @@ const MEMORY_LIMIT: usize = 64 * 1024 * 1024;
/// anyone reaches for Task Manager. Being *slow* is reported by [`timed`] instead.
const DEADLINE: Duration = Duration::from_secs(2);
+/// Exports can process the complete table once. Give them more time than per-edit validation while
+/// keeping a fixed cancellation point for runaway code.
+const EXPORT_DEADLINE: Duration = Duration::from_secs(10);
+
/// How long one call into a plugin may take before it is worth telling somebody about. A validator
/// runs per column on every edit, so anything at this scale is felt as the grid stuttering.
const SLOW: Duration = Duration::from_millis(150);
@@ -74,7 +79,12 @@ const HTTP_WINDOW: Duration = Duration::from_secs(60);
/// The descriptor shape this build understands. A plugin written against a later one is refused
/// rather than run half-understood; every version this host has ever known keeps working, the way
/// Zed keeps every old WIT world in the tree.
-const API_VERSION: u64 = 1;
+const API_VERSION: u64 = 2;
+
+const MAX_EXPORT_COLUMNS: usize = 512;
+const MAX_EXPORT_ROWS: usize = 250_000;
+const MAX_EXPORT_CELLS: usize = 2_000_000;
+const MAX_EXPORT_INPUT_BYTES: usize = 64 * 1024 * 1024;
/// The one permission that exists. Declared by a plugin, granted by the user, checked here.
pub const PERMISSION_NET: &str = "net";
@@ -98,14 +108,23 @@ pub struct Env {
pub storage: Json,
}
+pub struct PackageDescriptor {
+ pub name: String,
+ pub api_version: u64,
+ pub permissions: Vec,
+}
+
struct Loaded {
lua: Lua,
+ api_version: u64,
/// Overrides the file-derived id when the descriptor declares one.
name: Option,
description: Option,
validate: Option,
on_command: Option,
suggest: Option,
+ export: Option,
+ exports: Vec,
menu: Vec,
settings: Vec,
bar: Vec,
@@ -168,6 +187,41 @@ impl LuaPlugin {
}
}
+ pub fn load_packaged(
+ id: &str,
+ source: &str,
+ env: Env,
+ package: Result, String>,
+ ) -> Self {
+ let mut plugin = Self::load(id, source, env);
+ match package {
+ Err(error) => plugin.state = Err(error),
+ Ok(Some(package)) => {
+ let mismatch = plugin.state.as_ref().is_ok_and(|loaded| {
+ loaded.api_version != package.api_version
+ || loaded
+ .permissions
+ .iter()
+ .map(SharedString::as_ref)
+ .collect::>()
+ != package
+ .permissions
+ .iter()
+ .map(String::as_str)
+ .collect::>()
+ });
+ if mismatch {
+ plugin.state =
+ Err("runtime descriptor does not match qrate-plugin.json".to_string());
+ } else if let Ok(loaded) = plugin.state.as_mut() {
+ loaded.name = Some(package.name.into());
+ }
+ }
+ Ok(None) => {}
+ }
+ plugin
+ }
+
/// This plugin's storage if it changed since the last call, and nothing when it did not — so
/// the host writes a file only when there is something new in it.
pub fn take_storage(&self) -> Option {
@@ -234,7 +288,11 @@ impl LuaPlugin {
/// Every call into Lua restarts the compute budget the interrupt hook enforces.
fn arm(&self) {
- *self.shared.deadline.lock().unwrap() = Instant::now() + DEADLINE;
+ self.arm_for(DEADLINE);
+ }
+
+ fn arm_for(&self, duration: Duration) {
+ *self.shared.deadline.lock().unwrap() = Instant::now() + duration;
}
/// The plugin's own one-line description, shown on its Settings page.
@@ -250,6 +308,10 @@ impl LuaPlugin {
self.state.as_ref().map_or(&[], |l| l.settings.as_slice())
}
+ pub fn exports(&self) -> &[ExportSpec] {
+ self.state.as_ref().map_or(&[], |l| l.exports.as_slice())
+ }
+
/// Replace the project- and user-scope copies after something wrote to them.
pub fn set_scoped(&self, project: Json, user: Json) {
*self.scoped.lock().unwrap() = (project, user);
@@ -271,6 +333,68 @@ impl LuaPlugin {
.map_err(|err| err.to_string())
}
+ /// Run one declared JSON export against a bounded immutable table snapshot.
+ pub fn export(&self, id: &str, snapshot: &ExportSnapshot) -> Result {
+ validate_export_snapshot(snapshot)?;
+ let loaded = self.state.as_ref().map_err(String::clone)?;
+ ensure_export_declared(loaded, id)?;
+ let export = loaded
+ .export
+ .as_ref()
+ .ok_or_else(|| "has no `export` function".to_string())?;
+ let lua = &loaded.lua;
+ let table = lua.create_table().map_err(|error| error.to_string())?;
+ table
+ .set("title", snapshot.title.as_ref())
+ .map_err(|error| error.to_string())?;
+ let columns = lua.create_table().map_err(|error| error.to_string())?;
+ for (index, column) in snapshot.columns.iter().enumerate() {
+ let item = lua.create_table().map_err(|error| error.to_string())?;
+ item.set("name", column.name.as_ref())
+ .map_err(|error| error.to_string())?;
+ item.set("data_type", column.data_type.as_ref())
+ .map_err(|error| error.to_string())?;
+ item.set(
+ "settings",
+ to_lua(lua, &column.settings).map_err(|error| error.to_string())?,
+ )
+ .map_err(|error| error.to_string())?;
+ columns
+ .set(index + 1, item)
+ .map_err(|error| error.to_string())?;
+ }
+ table
+ .set("columns", columns)
+ .map_err(|error| error.to_string())?;
+ let rows = lua.create_table().map_err(|error| error.to_string())?;
+ for (index, row) in snapshot.rows.iter().enumerate() {
+ rows.set(
+ index + 1,
+ lua.create_sequence_from(row.iter().map(SharedString::as_ref))
+ .map_err(|error| error.to_string())?,
+ )
+ .map_err(|error| error.to_string())?;
+ }
+ table.set("rows", rows).map_err(|error| error.to_string())?;
+ table
+ .set(
+ "settings",
+ self.settings_table(lua, &Json::Null)
+ .map_err(|error| error.to_string())?,
+ )
+ .map_err(|error| error.to_string())?;
+
+ self.arm_for(EXPORT_DEADLINE);
+ let value: mlua::Value = timed(&self.id, &format!("export {id}"), || {
+ export.call((id, table))
+ })
+ .map_err(|error| error.to_string())?;
+ if value.is_nil() {
+ return Err("export returned no JSON value".to_string());
+ }
+ lua.from_value(value).map_err(|error| error.to_string())
+ }
+
fn call_command(
&self,
loaded: &Loaded,
@@ -421,6 +545,69 @@ fn json_field(lua: &Lua, table: &Table, key: &str) -> mlua::Result>
lua.from_value(value).map(Some)
}
+fn validate_export_snapshot(snapshot: &ExportSnapshot) -> Result<(), String> {
+ if snapshot.columns.len() > MAX_EXPORT_COLUMNS {
+ return Err(format!(
+ "table has too many columns for a plugin export (maximum {MAX_EXPORT_COLUMNS})"
+ ));
+ }
+ if snapshot.rows.len() > MAX_EXPORT_ROWS {
+ return Err(format!(
+ "table has too many rows for a plugin export (maximum {MAX_EXPORT_ROWS})"
+ ));
+ }
+ let cells = snapshot
+ .rows
+ .len()
+ .checked_mul(snapshot.columns.len())
+ .ok_or_else(|| "plugin export snapshot is too large".to_string())?;
+ if cells > MAX_EXPORT_CELLS {
+ return Err(format!(
+ "table has too many cells for a plugin export (maximum {MAX_EXPORT_CELLS})"
+ ));
+ }
+ if snapshot
+ .rows
+ .iter()
+ .any(|row| row.len() != snapshot.columns.len())
+ {
+ return Err("plugin export snapshot has inconsistent row widths".to_string());
+ }
+ let mut bytes = snapshot.title.len();
+ for column in &snapshot.columns {
+ bytes = bytes
+ .checked_add(column.name.len())
+ .and_then(|value| value.checked_add(column.data_type.len()))
+ .and_then(|value| {
+ serde_json::to_vec(&column.settings)
+ .ok()
+ .and_then(|settings| value.checked_add(settings.len()))
+ })
+ .ok_or_else(|| "plugin export snapshot is too large".to_string())?;
+ }
+ for value in snapshot.rows.iter().flatten() {
+ bytes = bytes
+ .checked_add(value.len())
+ .ok_or_else(|| "plugin export snapshot is too large".to_string())?;
+ }
+ if bytes > MAX_EXPORT_INPUT_BYTES {
+ return Err(format!(
+ "table has too much text for a plugin export (maximum {} MiB)",
+ MAX_EXPORT_INPUT_BYTES / 1024 / 1024
+ ));
+ }
+ Ok(())
+}
+
+fn ensure_export_declared(loaded: &Loaded, id: &str) -> Result<(), String> {
+ loaded
+ .exports
+ .iter()
+ .any(|item| item.id == id)
+ .then_some(())
+ .ok_or_else(|| format!("has no declared export {id:?}"))
+}
+
fn build(id: &str, source: &str, env: &Env, shared: &Shared) -> mlua::Result {
// Luau has no `io` and no `package` to remove, and its `debug` is already cut to two
// functions. Leaving out `OS` and `COROUTINE` costs a plugin its clock and its ability to
@@ -463,6 +650,32 @@ fn build(id: &str, source: &str, env: &Env, shared: &Shared) -> mlua::Result = descriptor.get("validate")?;
let on_command: Option = descriptor.get("on_command")?;
let suggest: Option = descriptor.get("suggest")?;
+ let export: Option = descriptor.get("export")?;
+ let exports = match descriptor.get::>>("exports")? {
+ Some(items) => items
+ .into_iter()
+ .map(export_spec)
+ .collect::>>()?,
+ None => Vec::new(),
+ };
+ if declared < 2 && (export.is_some() || !exports.is_empty()) {
+ return Err(mlua::Error::runtime("plugin exports require API version 2"));
+ }
+ if export.is_some() == exports.is_empty() {
+ return Err(mlua::Error::runtime(
+ "`export` and at least one `exports` item must be declared together",
+ ));
+ }
+ let mut export_ids = std::collections::HashSet::new();
+ if let Some(duplicate) = exports
+ .iter()
+ .find(|item| !export_ids.insert(item.id.as_ref()))
+ {
+ return Err(mlua::Error::runtime(format!(
+ "duplicate export id {:?}",
+ duplicate.id
+ )));
+ }
let menu = match descriptor.get::>>("menu")? {
Some(items) => items
.into_iter()
@@ -500,13 +713,14 @@ fn build(id: &str, source: &str, env: &Env, shared: &Shared) -> mlua::Result mlua::Result mlua::Result {
})
}
+fn export_spec(item: Table) -> mlua::Result {
+ let id: String = item.get("id")?;
+ let label: String = item.get("label")?;
+ let suggested_name: String = item.get("suggested_name")?;
+ if id.is_empty()
+ || !id
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_-".contains(&byte))
+ {
+ return Err(mlua::Error::runtime(format!("invalid export id {id:?}")));
+ }
+ if label.is_empty() {
+ return Err(mlua::Error::runtime("export label must not be empty"));
+ }
+ if suggested_name.is_empty()
+ || Path::new(&suggested_name)
+ .file_name()
+ .and_then(|name| name.to_str())
+ != Some(suggested_name.as_str())
+ {
+ return Err(mlua::Error::runtime(
+ "export suggested_name must be one filename",
+ ));
+ }
+ Ok(ExportSpec {
+ id: id.into(),
+ label: label.into(),
+ suggested_name: suggested_name.into(),
+ })
+}
+
fn column_map_spec(item: Table) -> mlua::Result {
Ok(ColumnMapSpec {
key: item.get::("key")?.into(),
@@ -870,10 +1117,13 @@ fn setting_spec(item: Table) -> mlua::Result {
#[cfg(test)]
mod tests {
use crate::plugin::HTTP_BUDGET;
- use crate::{Env, LuaPlugin, PERMISSION_NET, Writes};
+ use crate::{Env, LuaPlugin, PERMISSION_NET, PackageDescriptor, Writes};
use diagnostics::{ColumnInfo, ColumnValidator, Severity};
use gpui::SharedString;
- use plugin_api::{Bar, BarAction, CommandContext, MenuTarget, SettingKind, SettingScope, Side};
+ use plugin_api::{
+ Bar, BarAction, CommandContext, ExportColumn, ExportSnapshot, MenuTarget, SettingKind,
+ SettingScope, Side,
+ };
use serde_json::{Value as Json, json};
use settings::columns::ColumnSettings;
@@ -883,6 +1133,42 @@ mod tests {
LuaPlugin::load("test", source, granted())
}
+ #[test]
+ fn packaged_plugin_must_match_its_static_descriptor() {
+ let source = r#"return {
+ api_version = 1,
+ permissions = { "net" },
+ validate = function() return {} end,
+ }"#;
+ let matching = LuaPlugin::load_packaged(
+ "org.example.plugin",
+ source,
+ Env::default(),
+ Ok(Some(PackageDescriptor {
+ name: "Example plugin".to_string(),
+ api_version: 1,
+ permissions: vec!["net".to_string()],
+ })),
+ );
+ assert_eq!(matching.load_error(), None);
+ assert_eq!(matching.name(), "Example plugin");
+
+ let mismatch = LuaPlugin::load_packaged(
+ "org.example.plugin",
+ source,
+ Env::default(),
+ Ok(Some(PackageDescriptor {
+ name: "Example plugin".to_string(),
+ api_version: 1,
+ permissions: Vec::new(),
+ })),
+ );
+ assert_eq!(
+ mismatch.load_error(),
+ Some("runtime descriptor does not match qrate-plugin.json")
+ );
+ }
+
fn granted() -> Env {
Env {
granted: vec![PERMISSION_NET.to_string()],
@@ -1588,7 +1874,106 @@ mod tests {
fn a_plugin_written_against_a_later_api_refuses_to_load() {
let source = r#"return { api_version = 9, validate = function() return {} end }"#;
let err = plugin(source).load_error().unwrap_or_default().to_string();
- assert!(err.contains('9') && err.contains('1'), "{err:?}");
+ assert!(err.contains('9') && err.contains('2'), "{err:?}");
+ }
+
+ #[test]
+ fn exports_require_api_version_two() {
+ let source = r#"
+ return {
+ api_version = 1,
+ exports = {
+ { id = "json", label = "JSON…", suggested_name = "export.json" },
+ },
+ export = function() return {} end,
+ }
+ "#;
+ assert!(
+ plugin(source)
+ .load_error()
+ .is_some_and(|error| error.contains("API version 2"))
+ );
+ }
+
+ #[test]
+ fn export_receives_the_complete_snapshot_and_plugin_settings() {
+ let source = r#"
+ return {
+ api_version = 2,
+ exports = {
+ { id = "json", label = "Example JSON…", suggested_name = "example.json" },
+ },
+ export = function(id, snapshot)
+ return {
+ id = id,
+ title = snapshot.title,
+ column = snapshot.columns[1].name,
+ data_type = snapshot.columns[1].data_type,
+ mapping = snapshot.columns[1].settings.mapping,
+ value = snapshot.rows[2][1],
+ project = snapshot.settings.project.base,
+ subdelimiter = snapshot.settings.app.subdelimiter,
+ }
+ end,
+ }
+ "#;
+ let plugin = plugin(source);
+ assert_eq!(plugin.load_error(), None);
+ assert_eq!(plugin.exports().len(), 1);
+ plugin.set_scoped(json!({ "base": "https://example.test/" }), Json::Null);
+ plugin.set_app_settings(";".into());
+ let snapshot = ExportSnapshot {
+ title: "Collection".into(),
+ columns: vec![ExportColumn {
+ name: "Identifier".into(),
+ data_type: "URL".into(),
+ settings: json!({ "mapping": "id" }),
+ }],
+ rows: vec![vec!["one".into()], vec!["two".into()]],
+ };
+
+ assert_eq!(
+ plugin.export("json", &snapshot).unwrap(),
+ json!({
+ "id": "json",
+ "title": "Collection",
+ "column": "Identifier",
+ "data_type": "URL",
+ "mapping": "id",
+ "value": "two",
+ "project": "https://example.test/",
+ "subdelimiter": ";",
+ })
+ );
+ assert!(plugin.export("undeclared", &snapshot).is_err());
+ }
+
+ #[test]
+ fn export_rejects_inconsistent_rows_before_lua_runs() {
+ let source = r#"
+ return {
+ api_version = 2,
+ exports = {
+ { id = "json", label = "JSON…", suggested_name = "export.json" },
+ },
+ export = function() return {} end,
+ }
+ "#;
+ let snapshot = ExportSnapshot {
+ title: "Collection".into(),
+ columns: vec![ExportColumn {
+ name: "Identifier".into(),
+ data_type: "Text".into(),
+ settings: Json::Null,
+ }],
+ rows: vec![vec!["one".into(), "unexpected".into()]],
+ };
+
+ assert!(
+ plugin(source)
+ .export("json", &snapshot)
+ .is_err_and(|error| error.contains("inconsistent row widths"))
+ );
}
#[test]
diff --git a/crates/plugin-package/Cargo.toml b/crates/plugin-package/Cargo.toml
new file mode 100644
index 00000000..9038870a
--- /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", "json"] }
+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..765cc3c3
--- /dev/null
+++ b/crates/plugin-package/src/lib.rs
@@ -0,0 +1,1253 @@
+//! 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, SystemTime};
+
+use anyhow::{Context as _, Result, bail, ensure};
+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};
+use sha2::{Digest as _, Sha256};
+
+pub const CATALOG_KEY_ID: &str = "qrate-plugin-catalog-1";
+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 = 2;
+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 {
+ 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,
+ 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,
+ #[serde(default)]
+ pub installed_at_unix_seconds: 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 {
+ OfficialCatalog,
+ 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)
+ .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,
+ "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 (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()?;
+ 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")?;
+ Ok((bytes, signature))
+}
+
+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 mut response = reqwest::blocking::Client::builder()
+ .timeout(Duration::from_secs(60))
+ .build()?
+ .get(url)
+ .send()?;
+ ensure!(
+ response.status().is_success(),
+ "server returned HTTP {}",
+ response.status()
+ );
+ if let Some(length) = response.content_length() {
+ ensure!(length <= MAX_PACKAGE_BYTES, "download is too large");
+ }
+
+ let mut file = fs::OpenOptions::new()
+ .write(true)
+ .truncate(true)
+ .open(output)?;
+ let mut hasher = Sha256::new();
+ let mut total = 0_u64;
+ let mut buffer = [0_u8; 64 * 1024];
+ loop {
+ let read = response.read(&mut buffer)?;
+ if read == 0 {
+ break;
+ }
+ total = total
+ .checked_add(read as u64)
+ .ok_or_else(|| anyhow::anyhow!("download is too large"))?;
+ ensure!(total <= MAX_PACKAGE_BYTES, "download is too large");
+ hasher.update(&buffer[..read]);
+ file.write_all(&buffer[..read])?;
+ }
+ file.flush()?;
+ Ok((format!("{:x}", hasher.finalize()), total))
+}
+
+pub fn resolve_github_release(source: &str) -> Result {
+ let url = validate_github_url(source)?;
+ let parts: Vec<_> = url
+ .path_segments()
+ .into_iter()
+ .flatten()
+ .filter(|part| !part.is_empty())
+ .collect();
+ let owner = parts[0];
+ let repository = parts[1].trim_end_matches(".git");
+
+ 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 validate_github_source(source: &str) -> Result<()> {
+ validate_github_url(source).map(drop)
+}
+
+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");
+ 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,
+ receipts_root: &Path,
+ source: InstallSource,
+ expected: Option<(&CatalogPlugin, &CatalogRelease)>,
+) -> Result {
+ install_archive_checked(
+ archive,
+ plugins_root,
+ receipts_root,
+ source,
+ expected,
+ |_, _| Ok(()),
+ )
+}
+
+pub fn install_archive_checked(
+ archive: &Path,
+ plugins_root: &Path,
+ receipts_root: &Path,
+ source: InstallSource,
+ expected: Option<(&CatalogPlugin, &CatalogRelease)>,
+ check: impl FnOnce(&Path, &PackageManifest) -> Result<()>,
+) -> Result {
+ install_archive_inner(
+ archive,
+ plugins_root,
+ receipts_root,
+ source,
+ expected,
+ None,
+ check,
+ )
+}
+
+pub fn install_direct_archive(
+ archive: &Path,
+ plugins_root: &Path,
+ receipts_root: &Path,
+ release: &DirectRelease,
+) -> Result {
+ install_direct_archive_checked(archive, plugins_root, receipts_root, release, |_, _| Ok(()))
+}
+
+pub fn install_direct_archive_checked(
+ archive: &Path,
+ plugins_root: &Path,
+ receipts_root: &Path,
+ release: &DirectRelease,
+ check: impl FnOnce(&Path, &PackageManifest) -> Result<()>,
+) -> Result {
+ install_archive_inner(
+ archive,
+ plugins_root,
+ receipts_root,
+ InstallSource::DirectGithub,
+ None,
+ Some(release),
+ check,
+ )
+}
+
+fn install_archive_inner(
+ archive: &Path,
+ plugins_root: &Path,
+ receipts_root: &Path,
+ source: InstallSource,
+ expected: Option<(&CatalogPlugin, &CatalogRelease)>,
+ direct: Option<&DirectRelease>,
+ check: impl FnOnce(&Path, &PackageManifest) -> Result<()>,
+) -> 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.iter().collect::>()
+ == release.permissions.iter().collect::>(),
+ "package permissions do not match catalog"
+ );
+ }
+ check(staging.path(), &manifest).context("plugin failed its runtime contract check")?;
+
+ 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"
+ );
+ 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)?;
+ }
+ 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(
+ || {
+ direct.map_or_else(
+ || manifest.homepage.clone(),
+ |release| release.repository.clone(),
+ )
+ },
+ |(plugin, _)| plugin.repository.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,
+ installed_at_unix_seconds: SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs(),
+ };
+ 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_path = receipt_path(receipts_root, id)?;
+ let receipt: InstallReceipt = serde_json::from_slice(
+ &fs::read(&receipt_path).context("qrate does not manage this plugin")?,
+ )
+ .context("plugin installation receipt is invalid")?;
+ ensure!(
+ receipt.id == id,
+ "plugin installation receipt does not match"
+ );
+ let target = plugins_root.join(id);
+ if target.exists() {
+ let manifest: PackageManifest = serde_json::from_slice(
+ &fs::read(target.join("qrate-plugin.json"))
+ .context("managed plugin manifest is unavailable")?,
+ )
+ .context("managed plugin manifest is invalid")?;
+ ensure!(
+ manifest.id == receipt.id && manifest.version == receipt.version,
+ "installed plugin no longer matches its managed receipt"
+ );
+ fs::remove_dir_all(target)?;
+ }
+ fs::remove_file(receipt_path)?;
+ 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);
+ 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
+ .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 && 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(),
+ "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<()> {
+ let segments: Vec<_> = id.split('.').collect();
+ ensure!(
+ segments.len() >= 2
+ && id.len() <= 128
+ && segments.iter().all(|segment| {
+ !segment.is_empty()
+ && segment
+ .as_bytes()
+ .first()
+ .is_some_and(u8::is_ascii_alphanumeric)
+ && segment
+ .as_bytes()
+ .last()
+ .is_some_and(u8::is_ascii_alphanumeric)
+ && segment.bytes().all(|byte| {
+ byte.is_ascii_lowercase()
+ || byte.is_ascii_digit()
+ || matches!(byte, b'-' | b'_')
+ })
+ }),
+ "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 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")?;
+ 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(())
+}
+
+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;
+ use std::io::Write as _;
+
+ use anyhow::bail;
+ 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, DirectRelease, InstallSource, InstallTarget, PackageManifest,
+ install_archive, install_archive_checked, install_direct_archive, parse_install_link,
+ read_receipt, remove_managed, validate_github_source, 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 package_manifest_requires_an_explicit_permission_list() {
+ let manifest = json!({
+ "schema": 1,
+ "id": "org.example.plugin",
+ "name": "Example",
+ "version": "1.0.0",
+ "api_version": 1,
+ "entry": "init.lua",
+ "description": "Example",
+ "homepage": "https://example.org/plugin",
+ "license": "MIT"
+ });
+
+ assert!(serde_json::from_value::(manifest).is_err());
+ }
+
+ #[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 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()
+ );
+ for id in ["plugin", "org.-plugin", "org.plugin-", "org..plugin"] {
+ assert!(
+ parse_install_link(&format!("qrate://plugin/install?source=registry&id={id}"))
+ .is_err()
+ );
+ }
+ }
+
+ #[test]
+ fn direct_install_matcher_accepts_only_supported_github_links() {
+ for source in [
+ "https://github.com/owner/plugin",
+ "https://github.com/owner/plugin.git",
+ "https://github.com/owner/plugin/releases/tag/v1.0.0",
+ ] {
+ validate_github_source(source).unwrap();
+ }
+ for source in [
+ "git://github.com/owner/plugin.git",
+ "https://gitlab.com/owner/plugin",
+ "https://github.com/owner",
+ "https://github.com/owner/plugin/issues",
+ "https://user:password@github.com/owner/plugin",
+ ] {
+ assert!(validate_github_source(source).is_err(), "{source}");
+ }
+ }
+
+ #[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 a_direct_install_receipt_retains_its_update_source() {
+ let root = tempdir().unwrap();
+ let archive = root.path().join("plugin.zip");
+ package(&archive, "org.example.plugin", None);
+ let release = DirectRelease {
+ repository: "https://github.com/owner/plugin".into(),
+ version: "v1.0.0".into(),
+ release_url: "https://github.com/owner/plugin/releases/tag/v1.0.0".into(),
+ artifact_url: "https://github.com/owner/plugin/releases/download/v1.0.0/plugin.zip"
+ .into(),
+ artifact_name: "plugin.zip".into(),
+ bytes: fs::metadata(&archive).unwrap().len(),
+ };
+ let receipt = install_direct_archive(
+ &archive,
+ &root.path().join("plugins"),
+ &root.path().join("receipts"),
+ &release,
+ )
+ .unwrap();
+
+ assert_eq!(receipt.repository, release.repository);
+ assert_eq!(receipt.artifact_url, release.artifact_url);
+ }
+
+ #[test]
+ fn refuses_to_remove_a_managed_path_that_was_replaced() {
+ 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");
+ install_archive(
+ &archive,
+ &plugins,
+ &receipts,
+ InstallSource::DirectGithub,
+ None,
+ )
+ .unwrap();
+ let manifest = plugins.join("org.example.plugin/qrate-plugin.json");
+ let mut changed: serde_json::Value =
+ serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap();
+ changed["version"] = json!("2.0.0");
+ fs::write(&manifest, serde_json::to_vec(&changed).unwrap()).unwrap();
+
+ assert!(remove_managed("org.example.plugin", &plugins, &receipts).is_err());
+ assert!(plugins.join("org.example.plugin").is_dir());
+ }
+
+ #[test]
+ fn a_failed_runtime_check_leaves_the_installed_plugin_untouched() {
+ 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");
+ install_archive(
+ &archive,
+ &plugins,
+ &receipts,
+ InstallSource::DirectGithub,
+ None,
+ )
+ .unwrap();
+ let installed = plugins.join("org.example.plugin/init.lua");
+ fs::write(&installed, "-- installed version").unwrap();
+
+ let error = install_archive_checked(
+ &archive,
+ &plugins,
+ &receipts,
+ InstallSource::DirectGithub,
+ None,
+ |_, _| bail!("runtime mismatch"),
+ )
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("runtime mismatch"));
+ assert_eq!(
+ fs::read_to_string(installed).unwrap(),
+ "-- installed version"
+ );
+ }
+
+ #[test]
+ fn never_replaces_an_unmanaged_plugin_folder() {
+ let root = tempdir().unwrap();
+ let archive = root.path().join("plugin.zip");
+ package(&archive, "org.example.plugin", None);
+ let plugins = root.path().join("plugins");
+ fs::create_dir_all(plugins.join("org.example.plugin")).unwrap();
+ fs::write(
+ plugins.join("org.example.plugin/init.lua"),
+ "return { validate = function() return {} end }",
+ )
+ .unwrap();
+
+ assert!(
+ install_archive(
+ &archive,
+ &plugins,
+ &root.path().join("receipts"),
+ InstallSource::DirectGithub,
+ None,
+ )
+ .is_err()
+ );
+ assert_eq!(
+ fs::read_to_string(plugins.join("org.example.plugin/init.lua")).unwrap(),
+ "return { validate = function() return {} end }"
+ );
+ }
+
+ #[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()
+ );
+ }
+}
diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs
index f5f73bb4..1cf5ecf8 100644
--- a/crates/settings/src/lib.rs
+++ b/crates/settings/src/lib.rs
@@ -663,6 +663,8 @@ impl Render for SettingsWindow {
.gap_4()
.px_4()
.py_2()
+ .border_b_1()
+ .border_color(cx.theme().border)
.child(scope_tab(
"scope-user",
"User",
diff --git a/docs/dev/SETUP.md b/docs/dev/SETUP.md
index d2d18680..a2bc61ce 100644
--- a/docs/dev/SETUP.md
+++ b/docs/dev/SETUP.md
@@ -50,6 +50,34 @@ client id. Never commit the JSON; `.gitignore` covers `client_secret_*.json`, an
themselves belong in the environment or in the credential endpoint (`site-oauth-handoff.md`),
never in source.
+**Plugins in local Windows builds:** use the development runner to read the public catalog key,
+build qrate, register the debug executable for `qrate://`, run it, and remove that temporary
+registration when qrate exits:
+
+```powershell
+.\scripts\run-dev.ps1
+```
+
+Debug builds use `http://localhost:4321` for every qrate site route, including plugin discovery,
+catalog verification, feedback, Google configuration and Picker, release pages, and update feeds.
+Release builds use `https://qrate.dvnl.work`. A self-hosted build can set `QRATE_SITE_ORIGIN` at
+compile time to use its own site for all of those routes.
+
+This requires an authenticated GitHub CLI. To register an already-built debug executable without
+running it, use:
+
+```powershell
+.\scripts\register-dev-protocol.ps1
+```
+
+Browser links then launch `target\debug\app.exe`; if that development instance is already running,
+the new process hands the link to it and exits. Remove the development override before testing an
+installed build:
+
+```powershell
+.\scripts\register-dev-protocol.ps1 -Unregister
+```
+
---
## 3. One-time GitHub setup (do this before the first release)
diff --git a/docs/dev/plugin-marketplace-plan.md b/docs/dev/plugin-marketplace-plan.md
new file mode 100644
index 00000000..98444f09
--- /dev/null
+++ b/docs/dev/plugin-marketplace-plan.md
@@ -0,0 +1,826 @@
+# 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 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.
+
+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, 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
+
+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 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:
+
+```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;
+- exact release asset 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.
+
+`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.
+
+## 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 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
+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. The user pastes a GitHub repository or release URL.
+2. qrate resolves a selectable release artifact.
+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
+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.
+
+## 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;
+- 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;
+- 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.
+
+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,
+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
+
+- 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.
diff --git a/docs/plugins/api-reference.md b/docs/plugins/api-reference.md
index 6be36f1a..6fa605b7 100644
--- a/docs/plugins/api-reference.md
+++ b/docs/plugins/api-reference.md
@@ -7,14 +7,14 @@ sandboxes the virtual machine.
There is no third surface. Luau's `io`, `os`, and `package` are not loaded, so a capability that
is not on this page is one no plugin has.
-This page describes `api_version` 1. For a getting-started walkthrough, see
+This page describes `api_version` 2. For a getting-started walkthrough, see
[Plugins](index.md).
## The descriptor
```lua
return {
- api_version = 1,
+ api_version = 2,
name = "my-plugin",
description = "One line, shown on the Settings page.",
permissions = { "net" },
@@ -23,15 +23,18 @@ return {
menu = { … },
bar = { … },
column_map = { … },
+ exports = { … },
validate = function(column, values, settings) end,
on_command = function(command, ctx) end,
suggest = function(ctx) end,
+ export = function(id, snapshot) end,
}
```
Every field is optional, with one rule: the table must carry at least one of `validate`,
-`on_command`, or `suggest`. A descriptor with none of them does nothing, and qrate refuses it.
+`on_command`, `suggest`, or `export`. A descriptor with none of them does nothing, and qrate
+refuses it.
| Field | Value |
|---|---|
@@ -41,7 +44,7 @@ Every field is optional, with one rule: the table must carry at least one of `va
| `permissions` | What the plugin asks to be allowed to do. `net` is the only one. |
qrate reads the descriptor once, when it loads the plugin. To change a declaration, edit the file
-and click **Extensions ▸ Reload Plugins**.
+and click **Plugins ▸ Reload Plugins**.
### Identity
@@ -99,6 +102,50 @@ An error raised inside `validate` goes to the session log, and that run contribu
does not reach the Problems panel: the panel is a list of what is wrong with the data, not with
the code.
+## JSON exports
+
+Plugin exports require `api_version = 2`. API version 1 plugins continue to work without changes.
+
+Declare each File ▸ Export entry in `exports`:
+
+```lua
+exports = {
+ { id = "iiif", label = "IIIF Presentation 3…", suggested_name = "manifest.json" },
+},
+```
+
+Each `id` is local to the plugin. The `label` appears in the Export menu. The suggested name must
+be one file name without a path.
+
+The plugin returns a JSON value from `export`:
+
+```lua
+export = function(id, snapshot)
+ return {
+ label = snapshot.title,
+ items = snapshot.rows,
+ }
+end,
+```
+
+The snapshot does not change during the export. It has these fields:
+
+| Field | Value |
+|---|---|
+| `title` | The project display name. |
+| `columns` | Every column in source order. |
+| `rows` | Every row in source order. Each row has one string for each column. |
+| `settings` | [The settings table](#reading-settings). |
+
+Each column has `name`, `data_type`, and `settings`. The column settings contain only this plugin's
+stored object for that column.
+
+The plugin does not receive a path or file handle. qrate asks the user for a path and writes the
+returned JSON. The user can cancel before the plugin runs.
+
+qrate limits a snapshot to 512 columns, 250,000 rows, two million cells, and 64 MiB of text. qrate
+also limits the JSON output to 64 MiB. The Lua memory and execution limits still apply.
+
## Commands
A command is a name your plugin declares and qrate hands back to you when the user clicks. Both
diff --git a/docs/plugins/index.md b/docs/plugins/index.md
index 14c71420..8729278d 100644
--- a/docs/plugins/index.md
+++ b/docs/plugins/index.md
@@ -3,11 +3,30 @@
A plugin adds checks and commands to qrate. You write it in Lua. qrate runs it inside the app,
in a sandboxed [Luau](https://luau.org) virtual machine.
-A plugin is one folder of `.lua` files. There is no package manager, no registry, and no build
-step. You put the folder in qrate's plugins directory, and qrate finds it at startup.
+A plugin is one folder of `.lua` files. Install reviewed packages from the official catalog, review
+an unlisted public GitHub release, or put a local folder in qrate's plugins directory.
For the full list of hooks and host functions, see the [API reference](api-reference.md).
+## Install a plugin
+
+Open **Plugins ▸ Discover Plugins…** for releases in qrate's signed official catalog. qrate checks
+the catalog signature and the package hash before installation. An official listing means that the
+published metadata and exact release bytes were reviewed. It is not a guarantee that third-party
+code is harmless.
+
+Use **Plugins ▸ Install Plugin from Link…** for an unlisted public GitHub repository or release.
+qrate downloads the release ZIP, checks its static package manifest, and shows its source,
+permissions, size, and SHA-256 before it asks for confirmation.
+
+New package installs are enabled. Optional permissions such as network access stay off until you
+grant them under **Settings ▸ Plugins**. The same page shows whether qrate manages the package and
+can safely remove it.
+
+For local development or an offline installation, open **Plugins ▸ Plugins Folder** and copy a
+single `.lua` file or a plugin folder there. Manual plugins are unmanaged: qrate does not update or
+remove them.
+
## What a plugin can do
- **Check a column.** A plugin reports what is wrong with a column's values. Its findings go to
@@ -29,7 +48,7 @@ no `package`.
## How qrate loads a plugin
-qrate reads the plugins directory at startup. Open it with **Extensions ▸ Plugins Folder**.
+qrate reads the plugins directory at startup. Open it with **Plugins ▸ Plugins Folder**.
qrate accepts two shapes:
@@ -40,7 +59,8 @@ The name on disk is the plugin's identity. qrate keys the plugin's stored settin
switch, and its permission grants by that name. Renaming the folder later orphans everything the
plugin stored. Pick the name first.
-`init.lua` returns one table. That table is the manifest. There is no second file to write.
+`init.lua` returns the runtime descriptor. Release packages also have a static
+`qrate-plugin.json` manifest. qrate reads that manifest without running the plugin.
**Settings ▸ Plugins** lists every plugin qrate found, running or not. Each row carries an enable
switch, the plugin's one-line description, and a network switch when the plugin asks for one. A
@@ -60,7 +80,8 @@ The template holds a small working plugin and the complete type definitions.
| File | What it is |
|---|---|
-| `init.lua` | The plugin. The table it returns is the manifest. |
+| `init.lua` | The plugin. The table it returns is the runtime descriptor. |
+| `qrate-plugin.json` | Static identity, version, compatibility, license, and permission metadata. |
| `types/qrate.lua` | Type definitions for the whole API, with the reasoning attached. |
| `.luaurc` | Points luau-lsp at `types/`. |
@@ -118,15 +139,20 @@ Three things in that example apply to every plugin:
- `row` is 1-based, and it matches the `values` array you were handed.
- What `on_command` returns is what qrate stores. `validate` reads it back as `settings.column`.
-### 4. Load it
+### 4. Check and load it
+
+1. Run `npm run check` to validate the package metadata and archive contents.
+2. Copy or clone the folder into the plugins directory (**Plugins ▸ Plugins Folder**).
+3. Restart qrate, or click **Plugins ▸ Reload Plugins**.
+4. Open **Settings ▸ Plugins** and confirm your plugin is listed and enabled.
-1. Copy or clone the folder into the plugins directory (**Extensions ▸ Plugins Folder**).
-2. Restart qrate, or click **Extensions ▸ Reload Plugins**.
-3. Open **Settings ▸ Plugins** and confirm your plugin is listed and enabled.
+Tagging a version in a repository created from the template builds the versioned ZIP and checksum
+as GitHub Release assets. Follow the [marketplace submission guide](https://qrate.dvnl.work/plugins/submit/)
+to propose that release for the official catalog.
### 5. Iterate
-Edit the file, then click **Extensions ▸ Reload Plugins**. qrate rebuilds every plugin's virtual
+Edit the file, then click **Plugins ▸ Reload Plugins**. qrate rebuilds every plugin's virtual
machine and replaces every contribution. You do not have to restart the app.
qrate reports a plugin problem in two places, and the difference matters:
diff --git a/docs/plugins/islandora.md b/docs/plugins/islandora.md
index 0d26fb3f..1980f072 100644
--- a/docs/plugins/islandora.md
+++ b/docs/plugins/islandora.md
@@ -41,7 +41,7 @@ which the next edit starts.
## Install the plugin
-1. Open **Extensions ▸ Plugins Folder**.
+1. Open **Plugins ▸ Plugins Folder**.
2. Put the plugin folder there. To clone it, run
`git clone https://github.com/devnull03/qrate-islandora-plugin islandora`.
3. Name the folder `islandora`. qrate keys the plugin's stored settings by the folder name.
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 @@
+
+
+
+
+
+
+
+
+
+
+
+