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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions rust/contracts/rust-core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ or removing a command fails until the table changes in the same commit.
| `plugins.settings.catalog` | Project enabled plugin settings pages from installed manifests |
| `plugins.settings.navigation` | Project searched settings navigation without platform policy |
| `plugins.settings.delivery` | Route committed preference effects using native ownership facts |
| `plugins.declarative.settingsState` | Read complete plugin preference documents into Core-normalized rows, sources, and bounded native read plans |
| `plugins.declarative.settingsState` | Read complete plugin preference documents and packaged translations into Core-normalized rows, sources, and bounded native read plans |
| `plugins.declarative.settingsActionPlan` | Resolve an installed settings action and its native confirmation requirement |
| `plugins.installed.select` | Select numeric installed versions from native facts without reading paths |
| `plugins.installed.lifecycle` | Report installed plugins' enablement and plan confirming an install, enabling, disabling or uninstalling one |
Expand Down Expand Up @@ -4086,7 +4086,8 @@ Hosts never merge layers themselves.

`plugins.declarative.settingsState` accepts
`{ manifest, globalDocument?: string|null, projectDocument?: string|null,
owner?: { pluginID, version } }`.
owner?: { pluginID, version }, language?: string,
localizations?: { [path]: string } }`.
Send each complete captured UTF-8 document; null or omission means a missing
file. An existing empty, malformed, oversized, or duplicate-key document is an
error, not an empty layer. Native adapters must reject undecodable bytes rather
Expand All @@ -4098,16 +4099,42 @@ rejects a mismatched manifest ID or version with `plugin.settings.ownerChanged`
before returning editable sources or catalog metadata. This binds native package
identity; it neither enables capabilities nor grants process execution.

`language` is the interface language as a tag such as `zh-Hans` or `en`; send
it to show the plugin's own translation of its setting text (the
`l10n/<language>.json` files of the plugin contract). Without it the state is
not translated and `localization` is null. `localizations` holds the complete
UTF-8 text of each candidate file the native adapter found in the installed
package, keyed by the package-relative path from `localization.candidates`; a
missing file is left out, and `{}` means none exists. A first call with
`language` and without `localizations` returns the candidates to read, the same
way the first call without documents returns `storage`; both reads can be
answered in the second call. Native adapters read only these paths, only from
the installed package, and never write there.

Success returns `{ status: "ok", state }`. The state contains:

- `localization`: null without `language`; otherwise `{ language, candidates,
maximumBytes: 1048576, readRequired: bool, applied: string|null,
rejected: [{ path, error: { code, message, field? } }] }`. `candidates` are
`l10n/<tag>.json` for the tag and each shorter prefix, most specific first
(`zh-Hans-CN` gives `zh-Hans-CN`, `zh-Hans`, `zh`). `readRequired` is true
until `localizations` is sent. Core checks every supplied file, applies the
first candidate that passes every check and names it in `applied`; a valid
file after it is not used. Every file that fails a check, before or after
the applied one, is listed in `rejected` with code
`plugin.settings.invalidLocalization` and the `field` of its first problem
(absent when the problem is the file as a whole). A refused file is never
partly applied, and text a file leaves out stays as written in the manifest. Hosts
should log rejections for the plugin's author; the page still shows.
- `pluginID`, `actions`, and `rows` in validated declaration order. Each row has
`declaration`, normalized `scope` and `effect`, `global` and `effective`
`{ id, value, source }`, and `globalStored`/`projectStored` flags. `global` is
the value without project overrides; `effective` uses the existing resolver.
- `metadata`: `{ displayName, version, vendor }` from the validated manifest,
for installed-plugin presentation without reinterpreting the manifest in a host.
- `sections`: `{ title, rows: number[], actions: number[] }[]`, ordered by
section title (the empty heading first). References are zero-based indices
the manifest's section title (the empty heading first), so a translation
changes only the shown `title`. References are zero-based indices
into `rows` and `actions`; declaration order is retained within each section.
- `editors`: `{ global, project }`, each an array in exactly `rows` order.
Every entry contains `value`, `resetValue`, `source`, `editable`, `overridden`
Expand Down Expand Up @@ -4162,7 +4189,9 @@ commands. They do not alter the plugin/SDK protocol or justify advertising

Invalid documents use `plugin.settings.invalidDocument`; invalid batches,
values, or edit scopes use `plugin.settings.invalidEdit`, with a safe `field`.
Malformed envelopes use `invalid_request`; manifest failures retain existing
Malformed envelopes use `invalid_request`, as do a `language` that is not a
tag, `localizations` without `language`, and a `localizations` path that is
not one of the candidates. Manifest failures retain existing
rejection codes. Rejection never includes partial replacement text. Rust and
desktop consumers share `rust/fixtures/plugins/settings-documents.json`.

Expand Down Expand Up @@ -5928,6 +5957,8 @@ no function invocation. Otherwise an unconfirmed action whose declaration has
Once confirmed (or when the declaration needs no confirmation), `kind:"invoke"`
adds `function` and `params:{}`. Labels and function identity always come from
the validated manifest; callers cannot supply a different target or arguments.
`title` is therefore the manifest text; a host that shows a translated
settings page labels the confirmation with the state's `actions[].title`.
See `rust/fixtures/plugins/settings-actions.json`.

`confirmed` and `workspaceAvailable` are facts from the native owner, not a
Expand Down Expand Up @@ -5972,7 +6003,9 @@ with stable `key`, `kind` (`language` or `buildSystem`), opaque `id`, display
`sections`). Sections reference indices in the complete settingsState rows and
actions. Only enabled contributors create pages; action-only pages are valid.
Names come from enabled language/build declarations, with opaque ID fallback.
`settingsState.pages` supplies the same per-plugin placement projections.
`settingsState.pages` supplies the same per-plugin placement projections, with
section titles translated when the state is. Catalog contributor sections keep
the manifest headings.

`plugins.settings.navigation` accepts at most 576 two-level `items` (512 contributed pages plus 64 host entries) with stable
`id`, `title`, nullable `parent`, `group`, `expanded`, optional `keywords`, plus
Expand Down
160 changes: 160 additions & 0 deletions rust/crates/core/src/tests/plugin_settings_documents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,163 @@ fn settings_navigation_reserves_capacity_for_host_rows() {
assert_eq!(response["data"]["status"], "ok", "{response}");
assert_eq!(response["data"]["rows"].as_array().unwrap().len(), 513);
}

fn contract_settings_file(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/../../../shared/plugins/contract/v1/fixtures/settings/{name}",
env!("CARGO_MANIFEST_DIR")
))
.unwrap()
}

fn localized_state(language: &str, localizations: Option<Value>) -> Value {
let mut payload = json!({"manifest": manifest(), "language": language});
if let Some(localizations) = localizations {
payload["localizations"] = localizations;
}
let result = execute("plugins.declarative.settingsState", payload);
assert_eq!(result["data"]["status"], "ok", "{result}");
result["data"]["state"].clone()
}

fn section_titles(sections: &Value) -> Vec<String> {
sections
.as_array()
.unwrap()
.iter()
.map(|section| section["title"].as_str().unwrap().to_owned())
.collect()
}

#[test]
fn settings_state_names_translation_candidates_before_they_are_read() {
let plain = execute(
"plugins.declarative.settingsState",
json!({"manifest": manifest()}),
);
assert_eq!(plain["data"]["state"]["localization"], Value::Null);
let state = localized_state("zh-Hans", None);
assert_eq!(
state["localization"],
json!({"language":"zh-Hans","candidates":["l10n/zh-Hans.json","l10n/zh.json"],
"maximumBytes":1048576,"readRequired":true,"applied":null,"rejected":[]})
);
assert_eq!(state["rows"][0]["declaration"]["title"], "Checks");
}

#[test]
fn settings_state_shows_packaged_translation_and_keeps_manifest_identity() {
let text = contract_settings_file("l10n/zh-Hans.json");
let state = localized_state("zh-Hans", Some(json!({ "l10n/zh-Hans.json": text })));
assert_eq!(state["localization"]["applied"], "l10n/zh-Hans.json");
let row = |id: &str| {
state["rows"]
.as_array()
.unwrap()
.iter()
.find(|row| row["declaration"]["id"] == id)
.unwrap()
.clone()
};
let checks = row("checks");
assert_eq!(checks["declaration"]["title"], "检查项");
assert_eq!(checks["declaration"]["options"][0]["value"], "format");
assert_eq!(checks["declaration"]["options"][0]["title"], "格式检查");
assert_eq!(checks["declaration"]["options"][1]["title"], "Credo");
assert_eq!(row("dialyzer")["declaration"]["title"], "Dialyzer 静态分析");
let mix_env = row("mixEnv");
assert_eq!(mix_env["declaration"]["title"], "MIX_ENV");
assert_eq!(mix_env["declaration"]["section"], "Server");
assert_eq!(state["actions"][0]["title"], "重建 PLT");
assert_eq!(state["actions"][0]["function"], "dialyzer.rebuild");
let english = localized_state("en", Some(json!({})));
assert_eq!(english["localization"]["applied"], Value::Null);
assert_eq!(section_titles(&english["sections"]), ["", "Server"]);
// Translated headings keep the manifest's grouping and order.
assert_eq!(section_titles(&state["sections"]), ["", "服务器"]);
for (translated, original) in state["sections"]
.as_array()
.unwrap()
.iter()
.zip(english["sections"].as_array().unwrap())
{
assert_eq!(translated["rows"], original["rows"]);
assert_eq!(translated["actions"], original["actions"]);
}
let elixir = state["pages"]
.as_array()
.unwrap()
.iter()
.find(|page| page["key"] == "language:elixir")
.unwrap();
assert_eq!(section_titles(&elixir["sections"]), ["", "服务器"]);
}

#[test]
fn settings_state_localization_fixture_cases() {
let fixture: Value =
serde_json::from_str(&contract_settings_file("localization-cases.json")).unwrap();
assert_eq!(fixture["manifest"], "settings.plugin.json");
for case in fixture["cases"].as_array().unwrap() {
let files: serde_json::Map<String, Value> = case["files"]
.as_object()
.unwrap()
.iter()
.map(|(path, file)| {
let text = file
.as_str()
.map_or_else(|| file.to_string(), str::to_owned);
(path.clone(), json!(text))
})
.collect();
let state = localized_state(
case["language"].as_str().unwrap(),
Some(Value::Object(files)),
);
let report = &state["localization"];
assert_eq!(report["applied"], case["applied"], "case {}", case["name"]);
let rejected: Vec<_> = report["rejected"]
.as_array()
.unwrap()
.iter()
.map(|rejection| {
assert_eq!(
rejection["error"]["code"], "plugin.settings.invalidLocalization",
"case {}",
case["name"]
);
json!({"path": rejection["path"], "field": rejection["error"]["field"]})
})
.collect();
assert_eq!(
Value::Array(rejected),
case["rejected"],
"case {}",
case["name"]
);
}
}

#[test]
fn settings_state_refuses_malformed_localization_requests() {
for (payload, detail) in [
(json!({"language": "zh_Hans"}), "language must be a tag"),
(
json!({"language": "zh-Hans", "localizations": {"l10n/en.json": "{}"}}),
"not a candidate",
),
(json!({"localizations": {}}), "require language"),
] {
let mut request = payload.clone();
request["manifest"] = manifest();
let response = execute("plugins.declarative.settingsState", request);
assert_eq!(response["ok"], false, "{payload}: {response}");
assert_eq!(response["error"]["code"], "invalid_request", "{response}");
assert!(
response["error"]["details"]
.as_str()
.is_some_and(|details| details.contains(detail)),
"{response}"
);
}
}
26 changes: 24 additions & 2 deletions rust/crates/plugin-manifest/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
//! settings state, action, edit and workspace configuration commands.

use super::*;
use lithe_plugin_model::settings::LocalizationRequest;

impl DeclarativePluginManifest {
/// Checks the declared settings, sorts them by ID, and returns the IDs
Expand Down Expand Up @@ -129,13 +130,29 @@ struct SettingsStatePayload {
/// Missing files are null; an existing empty or malformed file is an error.
global_document: Option<String>,
project_document: Option<String>,
/// Interface language whose plugin translation to apply, such as `zh-Hans`.
#[serde(default)]
language: Option<String>,
/// Translation files the native adapter found among the state's
/// `localization.candidates`, by package-relative path. Absent until read.
#[serde(default)]
localizations: Option<BTreeMap<String, String>>,
}

/// `plugins.declarative.settingsState`: checked UI rows and raw declared launch
/// layers. Reading a state neither edits storage nor admits plugin capabilities.
pub fn settings_state_command(payload: Value) -> Result<Value, CoreError> {
let payload: SettingsStatePayload =
decode_payload(payload, "plugins.declarative.settingsState")?;
const COMMAND: &str = "plugins.declarative.settingsState";
let payload: SettingsStatePayload = decode_payload(payload, COMMAND)?;
let localization =
LocalizationRequest::new(payload.language.as_deref(), payload.localizations.as_ref())
.map_err(|reason| {
CoreError::new(
ErrorCode::InvalidRequest,
format!("Invalid {COMMAND} payload"),
)
.with_details(reason)
})?;
Ok(outcome(validate_manifest_value(payload.manifest).and_then(
|manifest| {
if payload.owner.as_ref().is_some_and(|owner| {
Expand All @@ -147,15 +164,20 @@ pub fn settings_state_command(payload: Value) -> Result<Value, CoreError> {
"The manifest does not match the captured installed package identity.",
));
}
let (translation, report) = localization.as_ref().map_or((None, Value::Null), |request| {
request.select(&manifest.settings, &manifest.setting_actions)
});
lithe_plugin_model::settings::document_state(
&manifest.id,
&manifest.settings,
&manifest.setting_actions,
payload.global_document.as_deref(),
payload.project_document.as_deref(),
translation.as_ref(),
)
.map(|mut state| {
state["metadata"] = json!({"displayName": manifest.display_name, "version":manifest.version, "vendor":manifest.vendor.display_name});
state["localization"] = report;
("state", state)
})
},
Expand Down
1 change: 1 addition & 0 deletions rust/crates/plugin-manifest/src/settings_surfaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ pub fn settings_catalog_command(payload: Value) -> Result<Value, CoreError> {
for page in lithe_plugin_model::settings::page_sections(
&manifest.settings,
&manifest.setting_actions,
None,
) {
let key = page["key"].as_str().unwrap_or_default().to_owned();
let target = pages.entry(key.clone()).or_insert_with(|| json!({
Expand Down
18 changes: 2 additions & 16 deletions rust/crates/plugin-marketplace/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
use lithe_plugin_manifest::{validate_manifest_value, SUPPORTED_PLUGIN_API_VERSIONS};
use lithe_plugin_model::capabilities::{self, HostCapabilities, Requires};
use lithe_plugin_model::{
decode_payload, is_official_namespace, is_plugin_id, is_semantic_version, PluginRejection,
Validation,
decode_payload, is_language_tag, is_official_namespace, is_plugin_id, is_semantic_version,
PluginRejection, Validation,
};
use lithe_protocol::CoreError;
use serde::Deserialize;
Expand Down Expand Up @@ -205,20 +205,6 @@ fn validate_index(
Ok(json!({ "plugins": plugins, "rejected": rejected }))
}

/// Language tag in the `language[-Script][-REGION]` form used by `.lproj`
/// directories, e.g. `zh-Hans` or `en`.
fn is_language_tag(tag: &str) -> bool {
let mut parts = tag.split('-');
let Some(language) = parts.next() else {
return false;
};
(2..=3).contains(&language.len())
&& language.chars().all(|c| c.is_ascii_lowercase())
&& parts.all(|part| {
(2..=8).contains(&part.len()) && part.chars().all(|c| c.is_ascii_alphanumeric())
})
}

fn validate_entry(
entry: IndexEntry,
source: SourceKind,
Expand Down
6 changes: 3 additions & 3 deletions rust/crates/plugin-model/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ mod version;
pub use icon::UIIcon;
pub use manifest::PluginManifest;
pub use names::{
is_bare_command, is_function_id, is_identifier, is_local_tool_id, is_official_namespace,
is_plugin_id, is_safe_relative_path, is_semantic_version, is_tool_id, is_tool_namespace,
sorted_unique, MAX_FUNCTION_ID_BYTES, OFFICIAL_PLUGIN_NAMESPACE,
is_bare_command, is_function_id, is_identifier, is_language_tag, is_local_tool_id,
is_official_namespace, is_plugin_id, is_safe_relative_path, is_semantic_version, is_tool_id,
is_tool_namespace, sorted_unique, MAX_FUNCTION_ID_BYTES, OFFICIAL_PLUGIN_NAMESPACE,
};
pub use rejection::{
decode_payload, invalid, invalid_template, outcome, PluginRejection, Validation,
Expand Down
14 changes: 14 additions & 0 deletions rust/crates/plugin-model/src/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ pub fn is_bare_command(value: &str) -> bool {
&& !value.chars().any(char::is_whitespace)
}

/// Language tag in the `language[-Script][-REGION]` form used by `.lproj`
/// directories and `l10n/` files, e.g. `zh-Hans` or `en`.
pub fn is_language_tag(tag: &str) -> bool {
let mut parts = tag.split('-');
let Some(language) = parts.next() else {
return false;
};
(2..=3).contains(&language.len())
&& language.chars().all(|c| c.is_ascii_lowercase())
&& parts.all(|part| {
(2..=8).contains(&part.len()) && part.chars().all(|c| c.is_ascii_alphanumeric())
})
}

/// A short identifier: lowercase letters, digits and `-`.
pub fn is_identifier(value: &str) -> bool {
!value.is_empty()
Expand Down
Loading
Loading