diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7a9c72e39..9a4af689a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,6 +26,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.id }}
+ official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v4
with: { ref: "${{ env.TAG }}" }
@@ -36,6 +37,20 @@ jobs:
echo "::error::tag $TAG does not match tauri.conf.json version $app"
exit 1
fi
+ # The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
+ # `openidea-plugin keygen` writes), built into the app: their packages
+ # must carry its signature whatever the marketplace index lists. Without
+ # the secret the app takes the index's word, as development builds do.
+ - id: plugin-keys
+ env:
+ SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
+ run: |
+ if [ -n "$SIGNING_KEY" ]; then
+ seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
+ key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
+ openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
+ echo "keys=$key" >> "$GITHUB_OUTPUT"
+ fi
- id: release
uses: actions/github-script@v7
with:
@@ -58,6 +73,8 @@ jobs:
build:
needs: create-release
name: Build (${{ matrix.name }})
+ env:
+ OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
strategy:
fail-fast: false
matrix:
diff --git a/Cargo.lock b/Cargo.lock
index a9bce3786..76ff7212d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4276,6 +4276,7 @@ dependencies = [
"regex-syntax",
"reqwest",
"rustix",
+ "semver",
"serde",
"serde_json",
"specta",
diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs
index 880f6e02a..fdf63d950 100644
--- a/crates/ide-extension/src/lib.rs
+++ b/crates/ide-extension/src/lib.rs
@@ -27,8 +27,8 @@ mod when;
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
pub use index::ContributionIndex;
pub use manifest::{
- ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest,
- check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
+ ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
+ Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
};
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs
index 25d19c45d..d079cff2d 100644
--- a/crates/ide-extension/src/manifest.rs
+++ b/crates/ide-extension/src/manifest.rs
@@ -38,8 +38,8 @@ pub const LEGACY_API: &str = "0.1";
/// Marketplace categories, in the order they are shown.
pub const CATEGORIES: &[&str] =
&["languages", "themes", "icon-themes", "linters", "vcs", "ai", "frameworks", "tools", "other"];
-/// What a manifest may declare in `categories`.
-const MAX_CATEGORIES: usize = 3;
+/// Categories a plugin is listed under; later ones are ignored.
+pub const MAX_CATEGORIES: usize = 3;
#[derive(Clone, Debug, PartialEq)]
pub struct Manifest {
@@ -300,9 +300,6 @@ impl Manifest {
return Err(format!("duplicate activation event {event}"));
}
}
- if self.declared_categories.len() > MAX_CATEGORIES {
- return Err(format!("`categories` lists at most {MAX_CATEGORIES}"));
- }
for c in &self.declared_categories {
validate_item_id(c, "category")?;
}
@@ -342,12 +339,18 @@ impl Manifest {
}
impl Manifest {
- /// Its marketplace categories, in [`CATEGORIES`] order: those declared that
- /// this IDE knows; without any, those its contributions imply (themes,
- /// icon themes, languages), else `other`.
+ /// Its marketplace categories, in [`CATEGORIES`] order: the first
+ /// [`MAX_CATEGORIES`] declared that this IDE knows; without any, those its
+ /// contributions imply (themes, icon themes, languages), else `other`.
pub fn categories(&self) -> Vec {
let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect();
- let mut out: Vec = pick(&|c| self.declared_categories.iter().any(|d| d == c));
+ let mut known: Vec<&str> = Vec::new();
+ for d in &self.declared_categories {
+ if CATEGORIES.contains(&d.as_str()) && !known.contains(&d.as_str()) && known.len() < MAX_CATEGORIES {
+ known.push(d);
+ }
+ }
+ let mut out: Vec = pick(&|c| known.contains(&c));
if out.is_empty() {
out = pick(&|c| match c {
"themes" => self.contributes.contains_key("themes"),
@@ -474,7 +477,9 @@ mod tests {
let declared_wins = m("categories = [\"tools\"]\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
assert_eq!(declared_wins.categories(), ["tools"]);
assert_eq!(m("").unwrap().categories(), ["other"]);
- assert!(m("categories = [\"a\", \"b\", \"c\", \"d\"]\n").unwrap_err().contains("at most 3"));
+ // At most three: the first ones declared.
+ let many = m("categories = [\"vcs\", \"ai\", \"vcs\", \"tools\", \"themes\"]\n").unwrap();
+ assert_eq!(many.categories(), ["vcs", "ai", "tools"]);
assert!(m("categories = [\"Bad Name\"]\n").is_err());
}
diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs
index e41c11fb2..f1bc9c040 100644
--- a/crates/ide-extension/src/package.rs
+++ b/crates/ide-extension/src/package.rs
@@ -14,6 +14,14 @@
//! revoked plugin version is disabled where it is installed. A plugin may be
//! listed in several versions, each with the plugin API it needs: the IDE
//! offers the newest one it can load.
+//! - **Key continuity**: an installed plugin updates silently only to a
+//! package signed with the same key, or with a key the installed one
+//! endorsed ([`Endorsement`], a publisher rotating keys); any other signer
+//! is the user's call. An IDE can pin a publisher to keys it was built with
+//! ([`Index::pin_publisher`]): the index then can't replace them.
+//! - **Format**: fields are only ever added (IDEs ignore those they don't
+//! know); `format` grows only for changes older IDEs can't read, and they
+//! still apply such an index's revocations ([`Index::parse`]).
use crate::MANIFEST_FILE;
use base64::Engine as _;
@@ -42,6 +50,24 @@ fn signed_message(id: &str, version: &str, digest: &str) -> Vec {
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
}
+fn endorsement_message(publisher: &str, key: &str) -> Vec {
+ format!("openidea-key/1\n{publisher}\n{key}").into_bytes()
+}
+
+/// Whether `signature` (base64) is `key`'s (base64) Ed25519 signature of
+/// `message`; malformed keys and signatures are errors.
+fn verify_ed25519(key: &str, message: &[u8], signature: &str) -> Result {
+ let key: [u8; 32] = B64
+ .decode(key)
+ .ok()
+ .and_then(|b| b.try_into().ok())
+ .ok_or_else(|| "signature: malformed public key".to_owned())?;
+ let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
+ let sig: [u8; 64] =
+ B64.decode(signature).ok().and_then(|b| b.try_into().ok()).ok_or_else(|| "signature: malformed".to_owned())?;
+ Ok(key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok())
+}
+
/// `SIGNATURE.json` of a signed package.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -76,6 +102,12 @@ impl PublisherKey {
B64.encode(self.0.verifying_key().to_bytes())
}
+ /// Vouches for `publisher`'s new key `key` (base64), the one that follows this one.
+ pub fn endorse(&self, publisher: &str, key: &str) -> Endorsement {
+ let sig = self.0.sign(&endorsement_message(publisher, key));
+ Endorsement { key: self.public_base64(), signature: B64.encode(sig.to_bytes()) }
+ }
+
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
let sig = self.0.sign(&signed_message(id, version, digest));
PackageSignature {
@@ -90,19 +122,11 @@ impl PackageSignature {
/// Whether the signature is valid for `id`, `version` and `digest` (the
/// key's validity is checked against an [`Index`]).
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
- let key: [u8; 32] = B64
- .decode(&self.key)
- .ok()
- .and_then(|b| b.try_into().ok())
- .ok_or_else(|| "signature: malformed public key".to_owned())?;
- let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
- let sig: [u8; 64] = B64
- .decode(&self.signature)
- .ok()
- .and_then(|b| b.try_into().ok())
- .ok_or_else(|| "signature: malformed".to_owned())?;
- key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
- .map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
+ if verify_ed25519(&self.key, &signed_message(id, version, digest), &self.signature)? {
+ Ok(())
+ } else {
+ Err("the signature does not match the package (it was changed after signing)".to_owned())
+ }
}
}
@@ -259,7 +283,7 @@ pub struct Publisher {
pub keys: Vec,
}
-#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct KeyEntry {
/// Ed25519 public key, base64.
@@ -267,6 +291,27 @@ pub struct KeyEntry {
/// Packages signed with it are no longer trusted.
#[serde(default)]
pub revoked: bool,
+ /// The publisher's earlier key vouching for this one.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub endorsement: Option,
+}
+
+impl KeyEntry {
+ pub fn new(key: String) -> Self {
+ Self { key, ..Self::default() }
+ }
+}
+
+/// A publisher's earlier key vouching for a newer one: plugins installed from
+/// packages signed with the earlier key update to ones signed with the newer
+/// key without asking the user.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
+#[serde(rename_all = "camelCase")]
+pub struct Endorsement {
+ /// The endorsing key, base64.
+ pub key: String,
+ /// Its Ed25519 signature of `openidea-key/1\n\n`, base64.
+ pub signature: String,
}
/// A published plugin version.
@@ -289,7 +334,7 @@ pub struct IndexEntry {
pub capabilities: Vec,
/// The plugin API versions it loads in (a semver requirement): its
/// manifest's `engines.openidea` and the API its component was built for.
- /// Empty: any.
+ /// Required: an entry without one is offered to no IDE.
#[serde(default)]
pub engine: String,
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
@@ -300,7 +345,7 @@ pub struct IndexEntry {
impl IndexEntry {
/// Whether this IDE can load the version.
pub fn compatible(&self) -> bool {
- self.engine.is_empty() || crate::engine_compatible(&self.engine, false)
+ crate::engine_compatible(&self.engine, false)
}
fn semver(&self) -> Option {
@@ -328,12 +373,34 @@ pub struct Revocation {
}
impl Index {
+ /// Parses an index. One in a newer format keeps what must reach every IDE —
+ /// revocations, publisher keys (revoked ones) and `moved` — and lists no
+ /// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
pub fn parse(text: &str) -> Result {
- let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
- if index.format != INDEX_FORMAT {
- return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
+ let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
+ let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
+ if format == u64::from(INDEX_FORMAT) {
+ return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
}
- Ok(index)
+ if format < u64::from(INDEX_FORMAT) {
+ return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
+ }
+ fn each(value: &serde_json::Value, field: &str) -> Vec {
+ let items = value.get(field).and_then(serde_json::Value::as_array);
+ items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
+ }
+ Ok(Index {
+ format: u32::try_from(format).unwrap_or(u32::MAX),
+ publishers: each(&value, "publishers"),
+ plugins: Vec::new(),
+ revoked: each(&value, "revoked"),
+ moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
+ })
+ }
+
+ /// Written in a format newer than this IDE reads: it lists no plugins.
+ pub fn is_newer(&self) -> bool {
+ self.format > INDEX_FORMAT
}
/// The version of each plugin to offer, and whether this IDE can load it:
@@ -407,6 +474,58 @@ impl Index {
Ok(publisher.id.clone())
}
+ /// Whether `to` is `from`, or a key of `publisher` that `from` endorsed,
+ /// directly or through keys it endorsed. Endorsements by revoked keys don't
+ /// count: whoever stole a key could vouch for their own.
+ pub fn succeeds(&self, publisher: &str, from: &str, to: &str) -> bool {
+ let keys = self.publishers.iter().find(|p| p.id == publisher).map_or(&[][..], |p| &p.keys[..]);
+ let mut key = to;
+ // Each step moves to an earlier key; more steps than keys is a loop.
+ for _ in 0..=keys.len() {
+ if key == from {
+ return true;
+ }
+ let Some(e) = keys.iter().find(|k| k.key == key).and_then(|k| k.endorsement.as_ref()) else {
+ return false;
+ };
+ let endorser_valid = keys.iter().any(|k| k.key == e.key && !k.revoked);
+ if !endorser_valid || verify_ed25519(&e.key, &endorsement_message(publisher, key), &e.signature) != Ok(true)
+ {
+ return false;
+ }
+ key = &e.key;
+ }
+ false
+ }
+
+ /// Makes `roots` (base64) the keys `publisher` signs with, whatever this
+ /// index says: another key it lists stays only when a root endorsed it (a
+ /// rotation the roots vouched for). Revocations of the keys are kept.
+ pub fn pin_publisher(&mut self, publisher: &str, name: &str, roots: &[String]) {
+ if roots.is_empty() {
+ return;
+ }
+ let i = match self.publishers.iter().position(|p| p.id == publisher) {
+ Some(i) => i,
+ None => {
+ self.publishers.push(Publisher { id: publisher.into(), name: name.into(), keys: Vec::new() });
+ self.publishers.len() - 1
+ }
+ };
+ for root in roots {
+ if !self.publishers[i].keys.iter().any(|k| &k.key == root) {
+ self.publishers[i].keys.push(KeyEntry::new(root.clone()));
+ }
+ }
+ let keys = self.publishers[i].keys.clone();
+ let trusted = keys
+ .into_iter()
+ .filter(|k| roots.iter().any(|r| self.succeeds(publisher, r, &k.key)))
+ .map(|k| if roots.contains(&k.key) { KeyEntry { endorsement: None, ..k } } else { k })
+ .collect();
+ self.publishers[i].keys = trusted;
+ }
+
/// Replaces what this index lists for every publisher but `keep` with what
/// `other` lists: its publishers, their plugins and their revocations
/// (`other`'s downloads should already be absolute). `other` may only
@@ -430,6 +549,9 @@ impl Index {
if !theirs.contains(&e.publisher.as_str()) || !owns(&e.publisher, &e.id) {
return Err(format!("{}: not a plugin of publisher {:?}", e.id, e.publisher));
}
+ if semver::VersionReq::parse(&e.engine).is_err() {
+ return Err(format!("{} {}: `engine` must be the plugin API it needs", e.id, e.version));
+ }
}
if let Some(r) = other.revoked.iter().find(|r| !owned_by(&r.id)) {
return Err(format!("{}: revoked by an index that doesn't publish it", r.id));
@@ -445,6 +567,25 @@ impl Index {
}
}
+/// Whether a package signed `new` (`None`: unsigned) may replace an installed
+/// one signed `installed` without asking the user: signed with the same key,
+/// or with one the installed key endorsed (per `index`). An unsigned installed
+/// copy pins nothing.
+pub fn signer_continues(
+ index: Option<&Index>,
+ installed: Option<&PackageSignature>,
+ new: Option<&PackageSignature>,
+) -> bool {
+ match (installed, new) {
+ (None, _) => true,
+ (Some(_), None) => false,
+ (Some(old), Some(new)) => {
+ old.publisher == new.publisher
+ && (old.key == new.key || index.is_some_and(|i| i.succeeds(&new.publisher, &old.key, &new.key)))
+ }
+ }
+}
+
/// Whether the plugin `id` is in `publisher`'s namespace.
fn owns(publisher: &str, id: &str) -> bool {
id.strip_prefix(publisher).is_some_and(|rest| rest.starts_with('.'))
@@ -472,7 +613,7 @@ mod tests {
publishers: vec![Publisher {
id: "acme".into(),
name: "ACME".into(),
- keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
+ keys: vec![KeyEntry::new(key.public_base64())],
}],
plugins: Vec::new(),
revoked: vec![Revocation {
@@ -529,7 +670,7 @@ mod tests {
download: format!("https://example.com/{id}.oiplugin"),
digest: "d".into(),
capabilities: Vec::new(),
- engine: String::new(),
+ engine: "^1.0".into(),
categories: Vec::new(),
}
}
@@ -562,7 +703,8 @@ mod tests {
let expected = [
("acme.a".to_owned(), "1.2.0".to_owned(), true),
("acme.b".to_owned(), "0.2.0".to_owned(), false),
- ("acme.c".to_owned(), "1.0.0".to_owned(), true),
+ // No `engine`: listed, but no IDE installs it.
+ ("acme.c".to_owned(), "1.0.0".to_owned(), false),
];
assert_eq!(offered(&index), expected);
@@ -579,7 +721,7 @@ mod tests {
}
fn publisher(id: &str) -> Publisher {
- Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry { key: format!("{id}-key"), revoked: false }] }
+ Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry::new(format!("{id}-key"))] }
}
fn revocation(id: &str) -> Revocation {
@@ -632,6 +774,14 @@ mod tests {
(with(vec![publisher("alice")], vec![entry("alice.a", "bob")], vec![]), "not a plugin of"),
(with(vec![publisher("alice")], vec![entry("alicex.a", "alice")], vec![]), "not a plugin of"),
(with(vec![publisher("alice")], vec![], vec![revocation("openidea.a")]), "doesn't publish"),
+ (
+ with(
+ vec![publisher("alice")],
+ vec![IndexEntry { engine: String::new(), ..entry("alice.a", "alice") }],
+ vec![],
+ ),
+ "`engine`",
+ ),
];
for (other, error) in cases {
let mut index = base.clone();
@@ -655,7 +805,7 @@ mod tests {
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
assert!(err.contains("invalid path"), "{err}");
assert!(!tmp.path().join("outside.txt").exists());
- assert!(Index::parse(r#"{"format": 2}"#).is_err());
+ assert!(Index::parse(r#"{"format": 2}"#).unwrap().is_newer());
}
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
@@ -704,4 +854,102 @@ mod tests {
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
}
}
+
+ #[test]
+ fn a_newer_index_still_revokes_and_moves() {
+ let newer = format!(
+ r#"{{"format": {}, "plugins": [{{"anything": "new"}}], "shape": "changed",
+ "publishers": [{{"id": "acme", "name": "ACME", "keys": [{{"key": "k1", "revoked": true}}]}}, {{"id": 5}}],
+ "revoked": [{{"id": "acme.bad", "reason": "malware"}}, {{"oops": 1}}],
+ "moved": "https://example.com/next.json"}}"#,
+ INDEX_FORMAT + 1
+ );
+ let index = Index::parse(&newer).unwrap();
+ assert!(index.is_newer());
+ assert!(index.plugins.is_empty() && index.offered().is_empty());
+ assert_eq!(index.revocation("acme.bad", "2.0.0").unwrap().reason, "malware");
+ assert!(index.key_revoked("k1"));
+ assert_eq!(index.moved.as_deref(), Some("https://example.com/next.json"));
+ // Fields this IDE doesn't know are ignored in its own format.
+ let current = format!(r#"{{"format": {INDEX_FORMAT}, "later": true, "plugins": []}}"#);
+ assert!(!Index::parse(¤t).unwrap().is_newer());
+ assert!(Index::parse(r#"{"format": 0}"#).unwrap_err().contains("not supported"));
+ assert!(Index::parse(r#"{"plugins": []}"#).is_err());
+ }
+
+ fn keys<'a>(index: &'a mut Index, publisher: &str) -> &'a mut Vec {
+ &mut index.publishers.iter_mut().find(|p| p.id == publisher).unwrap().keys
+ }
+
+ #[test]
+ fn rotated_keys_continue_through_endorsements() {
+ let (k1, k2, k3) =
+ (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
+ let mut index = index(&k1);
+ let endorsed = |by: &PublisherKey, key: &PublisherKey| KeyEntry {
+ endorsement: Some(by.endorse("acme", &key.public_base64())),
+ ..KeyEntry::new(key.public_base64())
+ };
+ keys(&mut index, "acme").push(endorsed(&k1, &k2));
+ keys(&mut index, "acme").push(endorsed(&k2, &k3));
+ let (p1, p2, p3) = (k1.public_base64(), k2.public_base64(), k3.public_base64());
+ assert!(index.succeeds("acme", &p1, &p1));
+ assert!(index.succeeds("acme", &p1, &p3), "through k2");
+ assert!(!index.succeeds("acme", &p3, &p1), "not backwards");
+ assert!(!index.succeeds("other", &p1, &p2));
+ // An endorsement for another publisher, or by a revoked key, doesn't count.
+ let mut foreign = index.clone();
+ keys(&mut foreign, "acme")[1].endorsement = Some(k1.endorse("evil", &p2));
+ assert!(!foreign.succeeds("acme", &p1, &p2));
+ let mut stolen = index.clone();
+ keys(&mut stolen, "acme")[0].revoked = true;
+ assert!(!stolen.succeeds("acme", &p1, &p2));
+
+ let sig = |key: &PublisherKey| key.sign("acme", "acme.tools", "1.0.0", "d");
+ assert!(signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&k3))));
+ assert!(signer_continues(None, Some(&sig(&k1)), Some(&sig(&k1))));
+ assert!(!signer_continues(None, Some(&sig(&k1)), Some(&sig(&k2))), "no index to vouch");
+ let stranger = PublisherKey::from_seed([4; 32]);
+ assert!(!signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&stranger))));
+ assert!(!signer_continues(Some(&index), Some(&sig(&k1)), None), "signed, then unsigned");
+ assert!(signer_continues(Some(&index), None, Some(&sig(&stranger))), "unsigned pins nothing");
+ }
+
+ #[test]
+ fn pinned_publishers_keep_their_built_in_keys() {
+ let (root, next, intruder) =
+ (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([5; 32]));
+ let mut index = index(&intruder);
+ index.publishers[0].id = "openidea".into();
+ keys(&mut index, "openidea").push(KeyEntry {
+ endorsement: Some(root.endorse("openidea", &next.public_base64())),
+ ..KeyEntry::new(next.public_base64())
+ });
+ let roots = [root.public_base64()];
+ let listed = |index: &Index| -> Vec<(String, bool)> {
+ let mut index = index.clone();
+ index.pin_publisher("openidea", "OpenIDEA", &roots);
+ keys(&mut index, "openidea").iter().map(|k| (k.key.clone(), k.revoked)).collect()
+ };
+ // The intruder's key goes, the successor the root endorsed stays, the root is added.
+ assert_eq!(listed(&index), [(next.public_base64(), false), (root.public_base64(), false)]);
+ let mut pinned = index.clone();
+ pinned.pin_publisher("openidea", "OpenIDEA", &roots);
+ let digest = "d";
+ let signed = |k: &PublisherKey| k.sign("openidea", "openidea.tools", "1.0.0", digest);
+ assert!(pinned.verify(&signed(&intruder), "openidea.tools", "1.0.0", digest).is_err());
+ assert!(pinned.verify(&signed(&next), "openidea.tools", "1.0.0", digest).is_ok());
+ // A revoked root stays revoked, and what it endorsed is no longer trusted.
+ keys(&mut index, "openidea").push(KeyEntry { revoked: true, ..KeyEntry::new(root.public_base64()) });
+ assert_eq!(listed(&index), [(root.public_base64(), true)]);
+
+ // A publisher missing from the index is added with its roots.
+ let mut empty = Index { format: INDEX_FORMAT, ..Index::default() };
+ empty.pin_publisher("openidea", "OpenIDEA", &[root.public_base64()]);
+ assert_eq!(empty.verify(&signed(&root), "openidea.tools", "1.0.0", digest).unwrap(), "openidea");
+ // No roots: the index decides.
+ let mut open = index.clone();
+ open.pin_publisher("openidea", "OpenIDEA", &[]);
+ assert_eq!(open, index);
+ }
}
diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml
index 70c843e77..a8473b6b7 100644
--- a/crates/ide-host/Cargo.toml
+++ b/crates/ide-host/Cargo.toml
@@ -46,6 +46,7 @@ ide-extension.workspace = true
ide-plugin.workspace = true
ide-tools = { path = "../ide-tools" }
ide-docker = { path = "../ide-docker" }
+semver = "1"
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs
index 107d6149d..c0bee922b 100644
--- a/crates/ide-host/src/bin/openidea-plugin/main.rs
+++ b/crates/ide-host/src/bin/openidea-plugin/main.rs
@@ -8,8 +8,9 @@
//! openidea-plugin unlink [dir]
//! openidea-plugin links # list linked folders
//! openidea-plugin keygen # a publisher's Ed25519 key
+//! openidea-plugin endorse --key OLD --publisher ID # vouch for a new key
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # -.oiplugin
-//! openidea-plugin index [--publisher ID --name NAME --key F] # marketplace index.json
+//! openidea-plugin index [--publisher ID --name NAME --key F [--previous-key F]] # marketplace index.json
//! openidea-plugin merge --keep ID # list another index's publishers
//! ```
//!
@@ -39,13 +40,19 @@ commands:
unlink [dir] stop loading a linked folder
links list linked folders
keygen create a publisher signing key (keep the file secret)
+ endorse --key F --publisher ID
+ print an earlier key's endorsement of a new one (JSON), for an
+ index the publisher doesn't write with `index --previous-key`
pack [dir] [--key F --publisher ID] [--out FILE]
write -.oiplugin, signed with the key
- index [--publisher ID --name NAME --key F] [--out FILE] [--download-base URL]
+ index [--publisher ID --name NAME --key F [--previous-key F]] [--out FILE]
+ [--download-base URL]
write the marketplace index.json for the packages in dir: with a
key, register it and list that publisher's plugins (earlier
versions stay for older IDEs); without, list only these packages,
- each checked against the index's publishers
+ each checked against the index's publishers. A new key replacing
+ an earlier one is endorsed with --previous-key, so installed
+ plugins update to it without asking
merge --keep ID [--out FILE]
replace every publisher but ID in index with those of other-index
(a path or URL), their plugins and revocations
@@ -63,6 +70,7 @@ fn main() -> ExitCode {
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
Some("links") => list_links(),
Some("keygen") => cmd_keygen(&args[1..]),
+ Some("endorse") => cmd_endorse(&args[1..]),
Some("pack") => cmd_pack(&args[1..]),
Some("index") => cmd_index(&args[1..]),
Some("merge") => cmd_merge(&args[1..]),
@@ -368,6 +376,13 @@ fn check(dir: &Path) -> Result<(), String> {
for c in manifest.unknown_categories() {
println!(" warning: unknown category {c:?} (ignored); use {}", ide_extension::CATEGORIES.join(", "));
}
+ if manifest.declared_categories.len() > ide_extension::MAX_CATEGORIES {
+ println!(
+ " warning: only the first {} known categories are used; {} are declared",
+ ide_extension::MAX_CATEGORIES,
+ manifest.declared_categories.len()
+ );
+ }
if !manifest.activation.is_empty() {
let events: Vec = manifest.activation.iter().map(ToString::to_string).collect();
println!(" activation: {}", events.join(", "));
@@ -500,6 +515,20 @@ fn cmd_keygen(args: &[String]) -> Result<(), String> {
Ok(())
}
+fn cmd_endorse(args: &[String]) -> Result<(), String> {
+ let a = Args::parse(args, &[], &["key", "publisher"])?;
+ let usage = "usage: openidea-plugin endorse --key --publisher ID";
+ let ([new], Some(key), Some(publisher)) = (a.positional.as_slice(), a.value("key"), a.value("publisher")) else {
+ return Err(usage.into());
+ };
+ if !package::publisher_id_ok(publisher) {
+ return Err(format!("invalid publisher id {publisher:?}"));
+ }
+ let endorsement = read_key(key)?.endorse(publisher, new.trim());
+ println!("{}", serde_json::to_string_pretty(&endorsement).map_err(|e| e.to_string())?);
+ Ok(())
+}
+
/// A fresh scratch directory, removed when dropped.
fn scratch_dir(what: &str) -> Result {
tempfile::Builder::new().prefix(&format!("openidea-plugin-{what}-")).tempdir().map_err(|e| e.to_string())
@@ -546,33 +575,25 @@ fn cmd_pack(args: &[String]) -> Result<(), String> {
}
fn cmd_index(args: &[String]) -> Result<(), String> {
- let a = Args::parse(args, &[], &["publisher", "name", "key", "out", "download-base"])?;
+ let a = Args::parse(args, &[], &["publisher", "name", "key", "previous-key", "out", "download-base"])?;
let dir = canonical(&a.dir()?)?;
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
let base = a.value("download-base").map(|b| b.trim_end_matches('/'));
// Revocations and other publishers of an existing index are kept.
let mut index = match std::fs::read_to_string(&out) {
- Ok(text) => Index::parse(&text)?,
+ Ok(text) => current_format(Index::parse(&text)?, &out.to_string_lossy())?,
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
};
let publisher = match (a.value("publisher"), a.value("key")) {
(Some(publisher), Some(key)) => {
let key = read_key(key)?;
let name = a.value("name").unwrap_or(publisher);
- // Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
- let current = key.public_base64();
- let earlier: Vec = index
- .publishers
- .iter()
- .filter(|p| p.id == publisher)
- .flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
- .collect();
- index.publishers.retain(|p| p.id != publisher);
- let keys: Vec =
- std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect();
- index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
+ let previous = a.value("previous-key").map(read_key).transpose()?;
+ index.publishers =
+ register_key(std::mem::take(&mut index.publishers), publisher, name, &key, previous.as_ref())?;
Some(publisher)
}
+ (None, None) if a.has("previous-key") => return Err("--previous-key goes with --publisher and --key".into()),
(None, None) => None,
_ => return Err("--publisher and --key go together".into()),
};
@@ -642,6 +663,60 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
write_index(&index, &out)
}
+/// `publishers` with `key` as `publisher`'s current key. Earlier keys stay listed
+/// (packages they signed remain valid until revoked); a key new to the
+/// publisher is endorsed by `previous`, one of them, so installed plugins
+/// signed with it update without asking.
+fn register_key(
+ mut publishers: Vec,
+ publisher: &str,
+ name: &str,
+ key: &PublisherKey,
+ previous: Option<&PublisherKey>,
+) -> Result, String> {
+ let current = key.public_base64();
+ let earlier: Vec = publishers.iter().filter(|p| p.id == publisher).flat_map(|p| p.keys.clone()).collect();
+ let entry = match earlier.iter().find(|k| k.key == current) {
+ Some(listed) => listed.clone(),
+ None => {
+ let endorsement = match previous {
+ Some(prev) => {
+ let prev_key = prev.public_base64();
+ if !earlier.iter().any(|k| k.key == prev_key && !k.revoked) {
+ return Err(format!("--previous-key is not one of {publisher}'s valid keys in the index"));
+ }
+ Some(prev.endorse(publisher, ¤t))
+ }
+ None => {
+ if earlier.iter().any(|k| !k.revoked) {
+ println!(
+ " warning: {publisher}'s new key is not endorsed (--previous-key): users who installed \
+ its plugins will be asked to confirm the change when they update"
+ );
+ }
+ None
+ }
+ };
+ KeyEntry { endorsement, ..KeyEntry::new(current.clone()) }
+ }
+ };
+ let keys = std::iter::once(entry).chain(earlier.into_iter().filter(|k| k.key != current)).collect();
+ publishers.retain(|p| p.id != publisher);
+ publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
+ Ok(publishers)
+}
+
+/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read).
+fn current_format(index: Index, file: &str) -> Result {
+ if index.is_newer() {
+ return Err(format!(
+ "{file}: index format {} is newer than this tool writes; update openidea-plugin",
+ index.format
+ ));
+ }
+ Ok(index)
+}
+
fn cmd_merge(args: &[String]) -> Result<(), String> {
let a = Args::parse(args, &[], &["keep", "out"])?;
let [base, other] = a.positional.as_slice() else {
@@ -649,9 +724,11 @@ fn cmd_merge(args: &[String]) -> Result<(), String> {
};
let keep = a.value("keep").ok_or("--keep is required")?;
let out = a.value("out").unwrap_or(base);
- let mut index = Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?;
+ let mut index =
+ current_format(Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?, base)?;
let bytes = read_market_url(other, 16 << 20)?;
- let mut theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
+ let theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
+ let mut theirs = current_format(theirs, other)?;
// Their downloads are relative to their index, not to ours.
for e in &mut theirs.plugins {
e.download = resolve_market_href(other, &e.download);
@@ -673,6 +750,25 @@ fn write_index(index: &Index, out: &Path) -> Result<(), String> {
mod tests {
use super::*;
+ #[test]
+ fn a_new_key_is_endorsed_by_the_previous_one() {
+ let (k1, k2, k3) =
+ (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
+ let first = register_key(Vec::new(), "acme", "ACME", &k1, None).unwrap();
+ let rotated = register_key(first.clone(), "acme", "ACME", &k2, Some(&k1)).unwrap();
+ let index = Index { format: package::INDEX_FORMAT, publishers: rotated.clone(), ..Index::default() };
+ assert!(index.succeeds("acme", &k1.public_base64(), &k2.public_base64()));
+ assert_eq!(rotated[0].keys.len(), 2, "the earlier key stays listed");
+ // Registering the current key again keeps its endorsement.
+ let again = register_key(rotated.clone(), "acme", "ACME", &k2, None).unwrap();
+ assert_eq!(again, rotated);
+ // Only a key the publisher has can endorse.
+ assert!(register_key(first.clone(), "acme", "ACME", &k2, Some(&k3)).unwrap_err().contains("--previous-key"));
+ // Without one the new key is listed unendorsed.
+ let unendorsed = register_key(first, "acme", "ACME", &k3, None).unwrap();
+ assert!(unendorsed[0].keys[0].endorsement.is_none());
+ }
+
#[test]
fn names() {
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
@@ -776,7 +872,7 @@ mod tests {
publishers: vec![package::Publisher {
id: "alice".into(),
name: "Alice".into(),
- keys: vec![KeyEntry { key: keys[1].1.clone(), revoked: false }],
+ keys: vec![KeyEntry::new(keys[1].1.clone())],
}],
..Index::default()
};
diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs
index deab1ebce..061b5df29 100644
--- a/crates/ide-host/src/plugins/market.rs
+++ b/crates/ide-host/src/plugins/market.rs
@@ -3,11 +3,14 @@
//! plugins, and revocations. The host keeps the last index it read
//! (`market-index.json`), checks signatures against it, and disables what it
//! revokes — at start-up and on every refresh (the desktop refreshes daily).
+//! Release builds pin the official publisher to the keys they were built
+//! with, so no index can sign `openidea.*` plugins with another key.
-use super::{PackageFile, PluginHost, PluginSource, PluginStatus};
+use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install};
use ide_core::{IdeError, IdeResult};
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
use ide_extension::{MANIFEST_FILE, Manifest};
+use semver::Version;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
@@ -15,6 +18,9 @@ use std::path::{Path, PathBuf};
/// the app, next to the signed packages of the official plugins.
pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json";
+/// The publisher of the official plugins.
+pub const OFFICIAL_PUBLISHER: &str = "openidea";
+
/// The last index read, in the plugins directory.
pub const INDEX_FILE: &str = "market-index.json";
const DOWNLOADS: &str = ".downloads";
@@ -37,6 +43,30 @@ pub struct MarketPlugin {
pub compatible: bool,
}
+impl MarketPlugin {
+ /// A version this IDE can load, newer than the one installed.
+ pub fn is_update(&self) -> bool {
+ let newer = |installed: &String| match (Version::parse(&self.entry.version), Version::parse(installed)) {
+ (Ok(offered), Ok(installed)) => offered > installed,
+ _ => false,
+ };
+ self.compatible && self.installed.as_ref().is_some_and(newer)
+ }
+}
+
+/// The official publisher's keys this IDE was built with
+/// (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`: base64, comma-separated). Development
+/// builds have none: the index decides.
+pub(super) fn built_in_official_keys() -> Vec {
+ let keys = option_env!("OPENIDEA_OFFICIAL_PLUGIN_KEYS").unwrap_or_default();
+ keys.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_owned).collect()
+}
+
+fn pinned(mut index: Index, official_keys: &[String]) -> Index {
+ index.pin_publisher(OFFICIAL_PUBLISHER, "OpenIDEA", official_keys);
+ index
+}
+
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
pub fn read_url(url: &str, max: usize) -> Result, String> {
if url.starts_with("https://") || url.starts_with("http://") {
@@ -108,9 +138,16 @@ pub fn resolve(base: &str, href: &str) -> String {
}
impl PluginHost {
- pub(super) fn load_market_index(dir: &Path) -> Option {
+ pub(super) fn load_market_index(dir: &Path, official_keys: &[String]) -> Option {
let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?;
- Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()
+ let index = Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()?;
+ Some(pinned(index, official_keys))
+ }
+
+ /// Pins the official publisher to `keys` (base64) instead of the built-in
+ /// ones, from the next index read.
+ pub fn set_official_keys(&self, keys: Vec) {
+ *self.official_keys.write() = keys;
}
/// The marketplace index URL: the user's, else the default (none: no marketplace).
@@ -128,14 +165,16 @@ impl PluginHost {
self.save_state();
}
- /// Reads the index again, keeps it, and applies its revocations.
+ /// Reads the index again, keeps it, and applies its revocations. Announces
+ /// updates of installed plugins it hasn't announced yet.
pub fn refresh_market(&self) -> IdeResult> {
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
let index = read_index(&url)?;
if let Ok(json) = serde_json::to_string(&index) {
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), json);
}
- *self.market.write() = Some(index);
+ let newer = index.is_newer().then_some(index.format);
+ *self.market.write() = Some(pinned(index, &self.official_keys.read()));
// Trust may have changed: who signed what, what is revoked.
let installed: Vec<_> =
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
@@ -144,7 +183,41 @@ impl PluginHost {
plugin.meta.lock().publisher = publisher;
}
self.apply_revocations();
- Ok(self.market_plugins())
+ if let Some(format) = newer {
+ return Err(IdeError::unavailable(format!(
+ "the marketplace needs a newer OpenIDEA (index format {format}); its revocations still apply"
+ )));
+ }
+ let plugins = self.market_plugins();
+ self.announce_updates(&plugins);
+ Ok(plugins)
+ }
+
+ /// Tells the windows about updates once per set of versions: a later
+ /// refresh offering the same ones says nothing.
+ fn announce_updates(&self, plugins: &[MarketPlugin]) {
+ let updates: Vec = plugins
+ .iter()
+ .filter(|p| p.is_update())
+ .map(|p| PluginUpdate {
+ id: p.entry.id.clone(),
+ name: p.entry.name.clone(),
+ version: p.entry.version.clone(),
+ })
+ .collect();
+ let key: Vec = updates.iter().map(|u| format!("{}@{}", u.id, u.version)).collect();
+ if std::mem::replace(&mut *self.announced_updates.lock(), key.clone()) != key && !updates.is_empty() {
+ self.emit(None, PluginEvent::UpdatesAvailable { updates });
+ }
+ }
+
+ /// Whether the package in `src` would replace the installed `id`'s copy
+ /// with one signed by another key than that copy, without that key's
+ /// endorsement: the user confirms the change of signer.
+ pub(super) fn signer_changed(&self, id: &str, src: &Path) -> bool {
+ let Some(current) = install::active_dir(&self.plugins_dir.join(id)) else { return false };
+ let market = self.market.read();
+ !package::signer_continues(market.as_ref(), read_signature(¤t).as_ref(), read_signature(src).as_ref())
}
/// The plugins of the last index read.
diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs
index c32c028c0..0b82a9350 100644
--- a/crates/ide-host/src/plugins/mod.rs
+++ b/crates/ide-host/src/plugins/mod.rs
@@ -37,7 +37,7 @@ pub mod web;
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix};
pub use market::{
- INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX,
+ INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
read_url as read_market_url, resolve as resolve_market_href,
};
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
@@ -245,12 +245,16 @@ pub struct Consent {
/// Optional capabilities the user accepted (`net:crates.io`, …). Required
/// ones are implied by consenting at all.
pub optional: Vec,
+ /// The user accepted a package signed by another key than the installed
+ /// copy ([`InstallPreview::signer_changed`]).
+ #[serde(default)]
+ pub signer_change: bool,
}
impl Consent {
/// Accept everything the plugin asks for (tests, trusted official plugins).
pub fn all() -> Self {
- Self { optional: vec!["*".into()] }
+ Self { optional: vec!["*".into()], signer_change: true }
}
fn accepts(&self, cap: &Capability) -> bool {
@@ -277,6 +281,14 @@ pub struct InstallPreview {
pub unsigned: bool,
/// The verified publisher.
pub publisher: Option,
+ /// Replaces an installed copy signed with another key, which didn't
+ /// endorse this package's (or this one is unsigned): the user confirms it
+ /// ([`Consent::signer_change`]).
+ pub signer_changed: bool,
+ /// Something to confirm: a first install, an unsigned package, another
+ /// signer, or capabilities the installed version wasn't granted or
+ /// refused. Without, an update installs with the grants it has.
+ pub needs_consent: bool,
}
type ProjectPolicy = Arc bool + Send + Sync>;
@@ -323,6 +335,10 @@ pub struct PluginHost {
pins: Arc>>,
/// The last marketplace index read: trust root for signatures, revocations.
market: RwLock
)}
+ {req.signerChanged && (
+
+
+ This version is signed with a different key than the installed one, and the publisher's earlier key
+ doesn't vouch for it. Only continue if you know the publisher changed keys.
+
+
+ )}
{caps.length === 0 ? (
This plugin needs no special permissions.
diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts
index 9a90df93b..57e1c11a0 100644
--- a/src/features/plugins/index.ts
+++ b/src/features/plugins/index.ts
@@ -33,7 +33,7 @@ import { defineBuiltinUiPoints } from "./points";
import { startViewUi } from "./uiPoints";
import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport";
import { IntellijThemesDialogHost } from "./IntellijThemesDialog";
-import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
+import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, updateAllPlugins, usePlugins } from "./store";
function registerPluginsPage(): () => void {
return registerSettingsPage({
@@ -89,6 +89,16 @@ export function activate(): () => void {
await installFromDisk(typeof arg === "string" ? arg : undefined);
},
},
+ {
+ id: "plugins.updateAll",
+ title: "Update All Plugins",
+ category: "Plugins",
+ icon: "plugin",
+ keywords: ["upgrade", "marketplace", "new version"],
+ run: async () => {
+ await updateAllPlugins();
+ },
+ },
{
id: "plugins.importIntellijTheme",
title: "Import IntelliJ Theme from File…",
diff --git a/src/features/plugins/store.ts b/src/features/plugins/store.ts
index c8defd2d1..797b7658b 100644
--- a/src/features/plugins/store.ts
+++ b/src/features/plugins/store.ts
@@ -71,6 +71,8 @@ export interface ConsentRequest {
capabilities: CapabilityInfo[];
/** Unsigned package (from disk or a project folder). */
unsigned: boolean;
+ /** Replaces an installed copy signed with another key that didn't vouch for this one. */
+ signerChanged?: boolean;
warnings: string[];
}
@@ -209,8 +211,13 @@ export async function setMarketUrl(url: string | null): Promise {
await refreshMarket(true);
}
+/** A version this IDE can load, newer than the one installed. */
+export function isUpdate(entry: MarketPlugin): boolean {
+ return entry.compatible && !!entry.installed && compareVersions(entry.version, entry.installed) > 0;
+}
+
/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */
-export async function installFromMarket(id: string): Promise {
+export async function installFromMarket(id: string, options: InstallOptions = {}): Promise {
usePlugins.setState({ installing: true });
let dir: string;
try {
@@ -225,11 +232,27 @@ export async function installFromMarket(id: string): Promise {
});
return null;
}
- const installed = await installFromDisk(dir);
+ const installed = await installFromDisk(dir, false, options);
void refreshMarket(false);
return installed;
}
+/**
+ * Installs every update the marketplace offers, one after another. Updates
+ * that need nothing confirmed install right away; the others show their
+ * install page. Returns how many were installed.
+ */
+export async function updateAllPlugins(): Promise {
+ await refreshMarket(true);
+ const updates = (usePlugins.getState().market ?? []).filter(isUpdate);
+ let updated = 0;
+ for (const entry of updates) if (await installFromMarket(entry.id, { quiet: true })) updated++;
+ if (updated > 0) {
+ notifications().notify({ group: "plugins", level: "info", title: `Updated ${updated} plugins` });
+ }
+ return updated;
+}
+
/** Settings › Plugins › Roll Back: the version before the last update. */
export async function rollBackPlugin(id: string): Promise {
try {
@@ -274,12 +297,22 @@ async function packageFolder(backend: PluginsBackend, picked: string): Promise {
+export async function installFromDisk(
+ path?: string,
+ packageFile = false,
+ options: InstallOptions = {},
+): Promise {
const backend = pluginsBackend();
let dir: string | null = path ?? null;
try {
@@ -300,24 +333,29 @@ export async function installFromDisk(path?: string, packageFile = false): Promi
let installed: string | null = null;
try {
const preview = await backend.previewInstall(dir);
- const consent = await askConsent({
- pluginId: preview.id,
- name: preview.name,
- version: preview.version,
- kind: "install",
- capabilities: preview.capabilities,
- unsigned: preview.unsigned,
- warnings: preview.warnings,
- });
+ const consent: Consent | null = preview.needsConsent
+ ? await askConsent({
+ pluginId: preview.id,
+ name: preview.name,
+ version: preview.version,
+ kind: "install",
+ capabilities: preview.capabilities,
+ unsigned: preview.unsigned,
+ signerChanged: preview.signerChanged,
+ warnings: preview.warnings,
+ })
+ : { optional: preview.capabilities.filter((c) => c.optional && c.granted).map((c) => c.id) };
if (!consent) return null;
const info = await backend.installDir(dir, consent);
installed = info.status === "failed" ? null : info.id;
- notifications().notify({
- group: "plugins",
- level: info.status === "failed" ? "warning" : "info",
- title: `Installed ${info.name} ${info.version}`,
- body: info.status === "failed" ? (info.error ?? "The plugin failed to start.") : undefined,
- });
+ if (info.status === "failed" || !options.quiet) {
+ notifications().notify({
+ group: "plugins",
+ level: info.status === "failed" ? "warning" : "info",
+ title: `Installed ${info.name} ${info.version}`,
+ body: info.status === "failed" ? (info.error ?? "The plugin failed to start.") : undefined,
+ });
+ }
return info.id;
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title: "Could not install plugin", body: errorText(e) });
diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json
index 507c77d7e..02cc037fd 100644
--- a/src/platform/locales/text/zh.json
+++ b/src/platform/locales/text/zh.json
@@ -6069,5 +6069,13 @@
"AI": "人工智能",
"Frameworks": "框架",
"All Categories": "全部分类",
- "Category": "分类"
+ "Category": "分类",
+ "Update All Plugins": "更新全部插件",
+ "Update All Plugins ({updates})": "更新全部插件({updates})",
+ "Update All": "全部更新",
+ "Show Plugins": "查看插件",
+ "Plugin update available": "有插件可更新",
+ "{length} plugin updates available": "{length} 个插件可更新",
+ "Updated {updated} plugins": "已更新 {updated} 个插件",
+ "This version is signed with a different key than the installed one, and the publisher's earlier key doesn't vouch for it. Only continue if you know the publisher changed keys.": "此版本的签名密钥与已安装的版本不同,且发布者之前的密钥没有为它背书。只有在确认发布者更换了密钥时才继续。"
}