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
12 changes: 12 additions & 0 deletions docs/features/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,18 @@ A plugin can declare `[[options]]` in its `manifest.toml` (`key` / `type` = `boo

Values persist in `<state_dir>/.plugin-config.json` ([`plugin_config`](../../src-tauri/crates/core/src/plugin/plugin_config.rs)) — the single source of truth, with no `app_setting` row and excluded from the scratch quota. They reach the guest through the read-only `waveflow:host/config.get-option` import, pinned at instantiate time. The import is additive: a plugin built before it still instantiates.

A `text` option saves on Enter and on leaving the field, and shows **Saved** once written — it used to save on blur alone, with no sign that it had.

### Credentials: `sensitive = true`

A cookie or a token a plugin signs in with is declared `sensitive = true` on its `text` option. The field becomes a password field that is **saved the moment something is pasted into it**, and the stored value **never travels back to the webview**: `get_plugin_options` sends `value: null` and `isSet`, so the panel can say a credential is stored — and offer to clear it — without holding it. It is still written in cleartext in `.plugin-config.json`, like every option; the flag is about the screen and the IPC, not the disk.

A flag rather than a fourth type on purpose: an older host ignores an unknown field and shows the option as plain text, where it would reject an unknown `type` and mark the plugin broken.

### Telling the user a credential was refused

A plugin whose credential was refused (an expired cookie, a revoked token) returns an error starting with **`auth-required:`**. [`plugin_attention`](../../src-tauri/crates/app/src/plugin_attention.rs) turns it into one toast ([`PluginAttentionToast`](../../src/components/common/PluginAttentionToast.tsx)) naming the plugin and pointing to Settings → Extensions — **once per launch and per plugin**, since the failure repeats on every track. Wired for the `canvas` and lyrics (`metadata/v2`) fan-outs. Every other error keeps its meaning (logged, skipped), and an older host logs the prefixed one the same way, so a plugin can adopt it without breaking there. Everything else a plugin cannot do is still `Ok(None)`: only "the user must act" is an error.

## Localized manifest strings

Plugin descriptions and option labels are authored in each plugin's `manifest.toml` (store descriptions in `registry.json`), outside the app's i18next files — so `t()` can never reach them. Instead the **format itself carries the translations**, for `plugin.description`, each option's `label` / `description`, and a registry entry's `description`.
Expand Down
4 changes: 3 additions & 1 deletion src-tauri/crates/app/src/commands/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,9 @@ pub async fn fetch_track_canvas(
}
Ok(Ok(Ok(None))) => { /* this plugin has no Canvas for the track */ }
Ok(Ok(Err(e))) => {
tracing::warn!(plugin_id, err = %e.detail(), "canvas plugin failed; skipping")
if !crate::plugin_attention::inspect(&plugin_id, &e) {
tracing::warn!(plugin_id, err = %e.detail(), "canvas plugin failed; skipping")
}
}
Ok(Err(e)) => tracing::warn!(plugin_id, %e, "canvas task panicked; skipping"),
Err(_) => tracing::warn!(plugin_id, "canvas plugin timed out; skipping"),
Expand Down
12 changes: 7 additions & 5 deletions src-tauri/crates/app/src/commands/lyrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2041,11 +2041,13 @@ async fn try_plugin_lyrics(
continue;
}
Ok(Ok(Err(err))) => {
tracing::debug!(
plugin = %plugin_id,
err = %err.detail(),
"plugin lyrics lookup failed"
);
if !crate::plugin_attention::inspect(&plugin_id, &err) {
tracing::debug!(
plugin = %plugin_id,
err = %err.detail(),
"plugin lyrics lookup failed"
);
}
continue;
}
Ok(Err(e)) => {
Expand Down
101 changes: 92 additions & 9 deletions src-tauri/crates/app/src/commands/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -954,7 +954,13 @@ pub struct PluginOption {
/// Plain string or `{ lang -> text }`, resolved frontend-side.
pub description: Option<LocalizedString>,
/// Current stored value; `None` = unset (the plugin uses `default`).
/// Always `None` for a sensitive option: see [`Self::is_set`].
pub value: Option<String>,
/// A credential: masked in the panel, its value never sent here.
pub sensitive: bool,
/// Whether a value is stored. The one thing the panel learns about a
/// sensitive option, so it can say "saved" without holding the token.
pub is_set: bool,
}

/// Validate a proposed option `value` against its manifest declaration.
Expand Down Expand Up @@ -1003,21 +1009,44 @@ pub async fn get_plugin_options(
Ok(manifest
.options
.into_iter()
.map(|o| PluginOption {
value: values.get(&o.key).cloned(),
key: o.key,
option_type: o.option_type,
label: o.label,
default: o.default,
choices: o.choices,
description: o.description,
})
.map(|o| to_plugin_option(o, &values))
.collect())
})
.await
.map_err(|e| AppError::Other(format!("spawn_blocking: {e}")))?
}

/// One manifest option, as the panel is allowed to see it.
///
/// A sensitive `text` option leaves its stored value behind: the panel
/// learns only whether one is set (`is_set`). That is the point of the
/// flag — a credential shown nowhere and never sent to the webview. Only a
/// text option is a credential: a `bool` or `enum` flagged sensitive by
/// mistake keeps its value, or the panel would show the default instead of
/// the setting.
fn to_plugin_option(
o: waveflow_core::plugin::manifest::OptionDecl,
values: &std::collections::HashMap<String, String>,
) -> PluginOption {
let sensitive =
o.sensitive && o.option_type == waveflow_core::plugin::manifest::option_types::TEXT;
PluginOption {
is_set: values.get(&o.key).is_some_and(|v| !v.is_empty()),
value: if sensitive {
None
} else {
values.get(&o.key).cloned()
},
sensitive,
key: o.key,
option_type: o.option_type,
label: o.label,
default: o.default,
choices: o.choices,
description: o.description,
}
}

/// Set (or reset, when `value` is `None`) one plugin option. Validates the
/// value against the manifest declaration, then rewrites the plugin's config
/// file. The new value takes effect the next time the plugin is instantiated
Expand Down Expand Up @@ -1331,3 +1360,57 @@ pub async fn open_plugins_folder(state: State<'_, AppState>) -> AppResult<()> {
.map_err(|e| AppError::Other(format!("open_path: {e}")))?;
Ok(())
}

#[cfg(test)]
mod option_masking_tests {
use super::*;
use std::collections::HashMap;
use waveflow_core::plugin::manifest::OptionDecl;

fn decl(key: &str, option_type: &str, sensitive: bool) -> OptionDecl {
OptionDecl {
key: key.into(),
option_type: option_type.into(),
label: LocalizedString::Plain(key.into()),
label_i18n: None,
default: None,
choices: vec!["a".into(), "b".into()],
description: None,
description_i18n: None,
sensitive,
}
}

fn stored(key: &str, value: &str) -> HashMap<String, String> {
HashMap::from([(key.to_string(), value.to_string())])
}

/// The property the flag exists for: a stored credential does not
/// travel to the webview, only the fact that there is one.
#[test]
fn a_stored_credential_never_leaves_the_backend() {
let opt = to_plugin_option(decl("sp_dc", "text", true), &stored("sp_dc", "secret"));
assert_eq!(opt.value, None);
assert!(opt.is_set);
assert!(opt.sensitive);
}

#[test]
fn an_empty_credential_is_not_set() {
let opt = to_plugin_option(decl("sp_dc", "text", true), &stored("sp_dc", ""));
assert!(!opt.is_set);
}

#[test]
fn a_plain_text_option_still_shows_its_value() {
let opt = to_plugin_option(decl("lang", "text", false), &stored("lang", "fr"));
assert_eq!(opt.value.as_deref(), Some("fr"));
}

#[test]
fn only_a_text_option_can_be_masked() {
let opt = to_plugin_option(decl("mode", "enum", true), &stored("mode", "b"));
assert_eq!(opt.value.as_deref(), Some("b"));
assert!(!opt.sensitive);
}
}
5 changes: 5 additions & 0 deletions src-tauri/crates/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod notifications;
mod offline;
mod paths;
mod player_actions;
mod plugin_attention;
mod queue;
mod render_mode;
// Remote play queue (RFC-005). Plain in-memory data — compiled
Expand Down Expand Up @@ -178,6 +179,9 @@ pub fn run() {
let setup_guard = render_mode::SetupGuard::new();
let init_handle = app.handle().clone();
let engine_handle = app.handle().clone();
// Before any plugin runs, so a credential refused on the very
// first lookup still reaches the user.
plugin_attention::init(app.handle().clone());

// Block on the async init — this runs once at startup before any
// command can be dispatched, so blocking here is acceptable.
Expand Down Expand Up @@ -1150,6 +1154,7 @@ pub fn run() {
commands::player::player_set_match_source_rate,
commands::inventory::inventory_summary,
commands::inventory::inventory_tracks,
plugin_attention::plugin_attention_history,
commands::inventory::inventory_phantom_artists,
commands::tag_fetch::search_album_tag_sources,
commands::tag_fetch::fetch_album_tag_proposals,
Expand Down
118 changes: 118 additions & 0 deletions src-tauri/crates/app/src/plugin_attention.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//! A plugin telling the user something only they can fix.
//!
//! Plugins that sign in with a credential the user pasted (a cookie, a
//! token) can have it expire or be revoked. Until now that looked exactly
//! like "no result": the plugin logged a warning nobody reads, and the
//! Canvas or the lyrics simply stopped appearing.
//!
//! The contract is a prefix on the error string a plugin returns:
//! [`AUTH_REQUIRED_PREFIX`]. The host turns it into one toast, **once per
//! launch and per plugin** — the failure repeats on every track, the
//! notice must not. Every other error keeps its old meaning (logged,
//! skipped), and a host older than this contract logs the prefixed one
//! the same way, so a plugin can adopt it without dropping support for
//! older hosts.

use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};

use serde::Serialize;
use tauri::{AppHandle, Emitter};
use waveflow_core::plugin::runtime::SourceError;

/// Start of a plugin error that means "the credential you gave me was
/// refused; paste a new one".
pub const AUTH_REQUIRED_PREFIX: &str = "auth-required:";

const EVENT: &str = "plugin:attention";

static APP: OnceLock<AppHandle> = OnceLock::new();
static ANNOUNCED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
/// The same notices, in order, for a window that starts listening after
/// they were emitted. Announced once per launch means an event nobody
/// heard is never sent again, and the first lookup at startup can race
/// the listener's registration.
static HISTORY: Mutex<Vec<String>> = Mutex::new(Vec::new());

#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AttentionPayload<'a> {
plugin_id: &'a str,
kind: &'static str,
}

/// Called once at startup, so a plugin path without an `AppHandle` in
/// reach (the lyrics waterfall) can still announce.
pub fn init(app: AppHandle) {
let _ = APP.set(app);
}

/// Whether `err` is the plugin saying its credential was refused.
pub fn is_auth_required(err: &SourceError) -> bool {
matches!(err, SourceError::Plugin(msg) if msg.starts_with(AUTH_REQUIRED_PREFIX))
}

/// Announce `err` to the user when it is an auth-required error, the first
/// time this plugin raises one in this launch. Returns whether it was one,
/// so the caller can skip its generic log line.
pub fn inspect(plugin_id: &str, err: &SourceError) -> bool {
if !is_auth_required(err) {
return false;
}
let first = {
let Ok(mut guard) = ANNOUNCED.lock() else {
return true;
};
guard
.get_or_insert_with(HashSet::new)
.insert(plugin_id.to_string())
};
if first {
if let Ok(mut history) = HISTORY.lock() {
history.push(plugin_id.to_string());
}
tracing::warn!(
plugin_id,
err = %err.detail(),
"plugin credential refused; asking the user for a new one"
);
if let Some(app) = APP.get() {
let _ = app.emit(
EVENT,
AttentionPayload {
plugin_id,
kind: "auth-required",
},
);
}
} else {
tracing::debug!(plugin_id, "plugin credential still refused");
}
true
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// The plugins whose credential was refused in this launch, in the order
/// they were announced. The notice reads this once it is listening, and
/// shows whichever it has not shown yet.
#[tauri::command]
pub fn plugin_attention_history() -> Vec<String> {
HISTORY.lock().map(|h| h.clone()).unwrap_or_default()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn only_the_prefixed_plugin_error_asks_for_a_credential() {
assert!(is_auth_required(&SourceError::Plugin(
"auth-required: sp_dc refused".into()
)));
assert!(!is_auth_required(&SourceError::Plugin(
"network timeout".into()
)));
assert!(!is_auth_required(&SourceError::Trap(
"auth-required: not from a trap".into()
)));
}
}
34 changes: 34 additions & 0 deletions src-tauri/crates/core/src/plugin/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,16 @@ pub struct OptionDecl {
/// into it at parse time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description_i18n: Option<BTreeMap<String, String>>,
/// A credential — a cookie, a token. The settings panel masks it,
/// and the host never sends its stored value back to the webview:
/// only whether one is set. Meaningful on `text` only.
///
/// A flag rather than a new type on purpose: an older host ignores a
/// field it does not know and reads the option as plain text, where
/// it would reject an unknown type and mark the plugin broken. A
/// plugin can set it without dropping support for older hosts.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub sensitive: bool,
}

#[derive(Debug, thiserror::Error)]
Expand Down Expand Up @@ -581,6 +591,30 @@ storage_read = true
assert_eq!(m.options[1].option_type, "bool");
}

/// A credential is flagged on a plain `text` option, and an option
/// that does not say so is not one.
#[test]
fn a_sensitive_option_is_a_flag_on_text() {
let raw = format!(
"{}
[[options]]
key = \"token\"
type = \"text\"
label = \"Token\"
sensitive = true

[[options]]
key = \"lang\"
type = \"text\"
label = \"Language\"
",
fixture(worlds::SOURCE_V1, &[])
);
let m = Manifest::parse(&raw).expect("valid options");
assert!(m.options[0].sensitive);
assert!(!m.options[1].sensitive);
}

#[test]
fn rejects_enum_option_without_choices() {
let raw = format!(
Expand Down
Loading
Loading