diff --git a/docs/features/plugins.md b/docs/features/plugins.md index 225737fd..25fc1d32 100644 --- a/docs/features/plugins.md +++ b/docs/features/plugins.md @@ -54,6 +54,18 @@ A plugin can declare `[[options]]` in its `manifest.toml` (`key` / `type` = `boo Values persist in `/.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`. diff --git a/src-tauri/crates/app/src/commands/canvas.rs b/src-tauri/crates/app/src/commands/canvas.rs index 4d87c29f..cf218773 100644 --- a/src-tauri/crates/app/src/commands/canvas.rs +++ b/src-tauri/crates/app/src/commands/canvas.rs @@ -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"), diff --git a/src-tauri/crates/app/src/commands/lyrics.rs b/src-tauri/crates/app/src/commands/lyrics.rs index fa350875..d05a4a69 100644 --- a/src-tauri/crates/app/src/commands/lyrics.rs +++ b/src-tauri/crates/app/src/commands/lyrics.rs @@ -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)) => { diff --git a/src-tauri/crates/app/src/commands/plugins.rs b/src-tauri/crates/app/src/commands/plugins.rs index a05c0d1a..88a66867 100644 --- a/src-tauri/crates/app/src/commands/plugins.rs +++ b/src-tauri/crates/app/src/commands/plugins.rs @@ -954,7 +954,13 @@ pub struct PluginOption { /// Plain string or `{ lang -> text }`, resolved frontend-side. pub description: Option, /// Current stored value; `None` = unset (the plugin uses `default`). + /// Always `None` for a sensitive option: see [`Self::is_set`]. pub value: Option, + /// 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. @@ -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, +) -> 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 @@ -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 { + 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); + } +} diff --git a/src-tauri/crates/app/src/lib.rs b/src-tauri/crates/app/src/lib.rs index a673e7b5..4f6ad789 100644 --- a/src-tauri/crates/app/src/lib.rs +++ b/src-tauri/crates/app/src/lib.rs @@ -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 @@ -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. @@ -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, diff --git a/src-tauri/crates/app/src/plugin_attention.rs b/src-tauri/crates/app/src/plugin_attention.rs new file mode 100644 index 00000000..873846eb --- /dev/null +++ b/src-tauri/crates/app/src/plugin_attention.rs @@ -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 = OnceLock::new(); +static ANNOUNCED: Mutex>> = 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> = 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 +} + +/// 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 { + 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() + ))); + } +} diff --git a/src-tauri/crates/core/src/plugin/manifest.rs b/src-tauri/crates/core/src/plugin/manifest.rs index 71a19e4a..f8b9d206 100644 --- a/src-tauri/crates/core/src/plugin/manifest.rs +++ b/src-tauri/crates/core/src/plugin/manifest.rs @@ -307,6 +307,16 @@ pub struct OptionDecl { /// into it at parse time. #[serde(default, skip_serializing_if = "Option::is_none")] pub description_i18n: Option>, + /// 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)] @@ -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!( diff --git a/src/components/common/PluginAttentionToast.tsx b/src/components/common/PluginAttentionToast.tsx new file mode 100644 index 00000000..ce4d7f76 --- /dev/null +++ b/src/components/common/PluginAttentionToast.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { KeyRound, X } from "lucide-react"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; + +import { listInstalledPlugins } from "../../lib/tauri/plugins"; + +const AUTO_HIDE_MS = 15_000; + +interface AttentionPayload { + pluginId: string; + kind: string; +} + +/** + * A plugin saying its credential was refused — an expired cookie, a + * revoked token — so it can do nothing until the user pastes a new one. + * + * Until this existed the failure looked exactly like "no result": the + * Canvas or the lyrics just stopped appearing. The backend sends + * `plugin:attention` once per launch and per plugin + * (`plugin_attention.rs`), however many tracks fail after it, so this + * shows once and does not nag. + * + * Mounted once in AppLayout, beside the playback toast, and portalled for + * the same reason (see the overlay invariant in CLAUDE.md). + */ +export function PluginAttentionToast() { + const { t } = useTranslation(); + // A queue, not a slot: two plugins can be refused at once (a cookie and + // a token expiring the same week), and each is announced only once per + // launch — a second notice overwriting the first would lose it for good. + const [queue, setQueue] = useState<{ pluginId: string; name: string }[]>([]); + const notice = queue.length > 0 ? queue[0] : null; + const dismiss = () => setQueue((prev) => prev.slice(1)); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + // Each plugin once: a notice can arrive both as an event and in the + // catch-up read below. + const shown = new Set(); + const announce = (pluginId: string) => { + if (shown.has(pluginId)) return; + shown.add(pluginId); + // The display name is the plugin's own; the id is the fallback so + // the toast never waits on a lookup that failed. + listInstalledPlugins() + .then((plugins) => plugins.find((p) => p.id === pluginId)?.name) + .catch(() => undefined) + .then((name) => { + if (!cancelled) + setQueue((prev) => [...prev, { pluginId, name: name ?? pluginId }]); + }); + }; + listen("plugin:attention", (event) => { + if (event.payload.kind !== "auth-required") return; + announce(event.payload.pluginId); + }) + .then((off) => { + if (cancelled) { + off(); + return; + } + unlisten = off; + // Anything announced before this listener existed: the backend + // sends each notice once, so a missed event would never return. + return invoke("plugin_attention_history").then((ids) => { + if (!cancelled) ids.forEach(announce); + }); + }) + .catch((err) => { + console.error("[PluginAttentionToast] listen failed", err); + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, []); + + // Keyed on the queue itself, so each notice gets its full time once it + // reaches the head, however long it waited behind another. + // Keyed on the head alone: a notice queued behind the one on screen must + // not restart its countdown. + // By id, not name: two plugins can share a display name, and a head + // that compares equal would never get a timer of its own. + const head = queue[0]?.pluginId; + useEffect(() => { + if (head == null) return; + const timer = window.setTimeout( + () => setQueue((prev) => prev.slice(1)), + AUTO_HIDE_MS, + ); + return () => window.clearTimeout(timer); + }, [head]); + + if (notice == null) return null; + + return createPortal( +
+
+
+
+
+
+ {t("settings.plugins.attention.authRequired.title", { + name: notice.name, + })} +
+

+ {t("settings.plugins.attention.authRequired.body")} +

+
+ +
+
, + document.body, + ); +} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 264b0c92..75968b11 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -38,6 +38,7 @@ import { UpdateBanner } from "../common/UpdateBanner"; import { ScanProgressToast } from "../common/ScanProgressToast"; import { TaskStatusBar } from "./TaskStatusBar"; import { PlaybackAlertToast } from "../common/PlaybackAlertToast"; +import { PluginAttentionToast } from "../common/PluginAttentionToast"; import { OnboardingModal } from "../common/OnboardingModal"; import { ViewSuspenseFallback } from "../common/ViewSuspenseFallback"; import { PageScrollContext } from "../../contexts/PageScrollContext"; @@ -839,6 +840,7 @@ export function AppLayout() { + {showOnboarding && } diff --git a/src/components/views/settings/PluginOptions.tsx b/src/components/views/settings/PluginOptions.tsx index e82c4b79..622f7188 100644 --- a/src/components/views/settings/PluginOptions.tsx +++ b/src/components/views/settings/PluginOptions.tsx @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Check } from "lucide-react"; import { getPluginOptions, @@ -43,8 +45,18 @@ function ManifestOptions({ pluginId }: { pluginId: string }) { const [options, setOptions] = useState([]); const [loading, setLoading] = useState(true); const [savingKey, setSavingKey] = useState(null); + // The option just written, for the "Saved" confirmation. A text field + // gave no sign of having been saved at all, so people pasted a token + // and did not know whether to press Enter. + const [savedKey, setSavedKey] = useState(null); const [error, setError] = useState(null); + useEffect(() => { + if (savedKey == null) return; + const timer = window.setTimeout(() => setSavedKey(null), 2500); + return () => window.clearTimeout(timer); + }, [savedKey]); + useEffect(() => { let cancelled = false; getPluginOptions(pluginId).then( @@ -68,12 +80,23 @@ function ManifestOptions({ pluginId }: { pluginId: string }) { async (key: string, value: string | null) => { if (savingKey) return; setSavingKey(key); + setSavedKey(null); setError(null); setOptions((prev) => - prev.map((o) => (o.key === key ? { ...o, value } : o)), + prev.map((o) => + o.key === key + ? { + ...o, + // A sensitive value is never held here, only whether one is. + value: o.sensitive ? null : value, + isSet: value != null && value !== "", + } + : o, + ), ); // optimistic try { await setPluginOption(pluginId, key, value); + setSavedKey(key); } catch (e) { setError(e instanceof Error ? e.message : String(e)); // Revert to the persisted truth. @@ -103,6 +126,7 @@ function ManifestOptions({ pluginId }: { pluginId: string }) { key={option.key} option={option} disabled={savingKey !== null} + saved={savedKey === option.key} onChange={(v) => onChange(option.key, v)} /> ))} @@ -113,12 +137,15 @@ function ManifestOptions({ pluginId }: { pluginId: string }) { function OptionControl({ option, disabled, + saved, onChange, }: { option: PluginOption; disabled: boolean; + saved: boolean; onChange: (value: string | null) => void; }) { + const { t } = useTranslation(); const localized = useLocalizedText(); // Effective value = user override, else the manifest default. const effective = option.value ?? option.default ?? ""; @@ -172,17 +199,123 @@ function OptionControl({ ))} ) : ( - { - if (e.target.value !== effective) onChange(e.target.value); - }} - className="shrink-0 w-40 text-sm rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-50" + saved={saved} + onChange={onChange} + savedLabel={t("settings.plugins.options.saved")} + storedPlaceholder={t("settings.plugins.options.secretStored")} + clearLabel={t("settings.plugins.options.secretClear")} /> )} ); } + +/** + * A `text` option. Saves on Enter and on leaving the field — a credential + * also the moment it is pasted — and says so: it used to save on blur + * alone, silently. + * + * A sensitive option (a cookie, a token) is a password field that starts + * empty: the host never sends the stored value back, only whether there + * is one, which the placeholder says. Pasting replaces it; the clear + * button removes it. + */ +function TextOption({ + option, + effective, + label, + disabled, + saved, + onChange, + savedLabel, + storedPlaceholder, + clearLabel, +}: { + option: PluginOption; + effective: string; + label: string; + disabled: boolean; + saved: boolean; + onChange: (value: string | null) => void; + savedLabel: string; + storedPlaceholder: string; + clearLabel: string; +}) { + const sensitive = option.sensitive; + const [draft, setDraft] = useState(sensitive ? "" : effective); + + const commit = (value: string) => { + const next = value.trim(); + if (sensitive) { + if (next === "") return; + onChange(next); + setDraft(""); + return; + } + if (next !== effective) onChange(next); + }; + + return ( +
+ {saved && ( + + + )} + setDraft(e.target.value)} + onPaste={(e) => { + // A credential is saved the moment it is pasted: pasting it is + // the whole gesture, and nothing on screen said Enter was + // needed. A plain text option keeps ordinary editing — a paste + // may land in the middle of what is there. + if (!sensitive) return; + const pasted = e.clipboardData.getData("text"); + if (!pasted) return; + e.preventDefault(); + // What the field would hold after the paste, selection included: + // normally the whole (empty) field, but not always. + const input = e.currentTarget; + const start = input.selectionStart ?? draft.length; + const end = input.selectionEnd ?? draft.length; + commit(draft.slice(0, start) + pasted + draft.slice(end)); + }} + onKeyDown={(e) => { + if (e.key === "Enter") commit(draft); + }} + onBlur={() => commit(draft)} + className="w-40 text-sm rounded-md border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-zinc-700 dark:text-zinc-200 px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 disabled:opacity-50" + /> + {sensitive && option.isSet && ( + + )} +
+ ); +} diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 6689e09e..3b14cc09 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1834,7 +1834,19 @@ }, "bundled": "مدمج", "bundledHint": "يأتي مع WaveFlow — قم بتعطيله لإخفائه.", - "optionsAria": "خيارات {{name}}" + "optionsAria": "خيارات {{name}}", + "options": { + "saved": "تم الحفظ", + "secretStored": "محفوظ — الصق للاستبدال", + "secretClear": "مسح" + }, + "attention": { + "authRequired": { + "title": "{{name}}: رُفض تسجيل الدخول", + "body": "انتهت صلاحية ملف تعريف الارتباط أو الرمز المحفوظ أو أُلغي. الصق رمزًا جديدًا في الإعدادات ← الإضافات." + }, + "dismiss": "إغلاق" + } }, "shortcuts": { "subtitle": "انقر على اختصار ثم اضغط على تركيبة المفاتيح المطلوبة. Backspace للمسح، Esc للإلغاء.", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f00e9e99..0607f2e2 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1665,7 +1665,19 @@ }, "bundled": "Mitgeliefert", "bundledHint": "Mit WaveFlow ausgeliefert — deaktivieren, um es auszublenden.", - "optionsAria": "Optionen für {{name}}" + "optionsAria": "Optionen für {{name}}", + "options": { + "saved": "Gespeichert", + "secretStored": "Gespeichert — zum Ersetzen einfügen", + "secretClear": "Löschen" + }, + "attention": { + "authRequired": { + "title": "{{name}}: Anmeldung abgelehnt", + "body": "Das gespeicherte Cookie oder Token ist abgelaufen oder wurde widerrufen. Füge in Einstellungen → Erweiterungen ein neues ein." + }, + "dismiss": "Schließen" + } }, "shortcuts": { "subtitle": "Klicke auf ein Tastenkürzel und drücke dann die gewünschte Tastenkombination. Rücktaste zum Löschen, Esc zum Abbrechen.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 274465c5..416107a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1665,7 +1665,19 @@ }, "bundled": "Built-in", "bundledHint": "Ships with WaveFlow — disable it to hide it.", - "optionsAria": "Options for {{name}}" + "optionsAria": "Options for {{name}}", + "options": { + "saved": "Saved", + "secretStored": "Saved — paste to replace", + "secretClear": "Clear" + }, + "attention": { + "authRequired": { + "title": "{{name}}: sign-in refused", + "body": "The saved cookie or token has expired or been revoked. Paste a new one in Settings → Extensions." + }, + "dismiss": "Dismiss" + } }, "shortcuts": { "subtitle": "Click a shortcut then press the key combination you want. Backspace to clear, Escape to cancel.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 36fc03f9..7c07f6f0 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1668,7 +1668,19 @@ }, "bundled": "Integrado", "bundledHint": "Incluido con WaveFlow — desactívalo para ocultarlo.", - "optionsAria": "Opciones de {{name}}" + "optionsAria": "Opciones de {{name}}", + "options": { + "saved": "Guardado", + "secretStored": "Guardado — pega para reemplazar", + "secretClear": "Borrar" + }, + "attention": { + "authRequired": { + "title": "{{name}}: acceso rechazado", + "body": "La cookie o el token guardado ha caducado o ha sido revocado. Pega uno nuevo en Configuración → Extensiones." + }, + "dismiss": "Cerrar" + } }, "shortcuts": { "subtitle": "Haz clic en un atajo y pulsa la combinación de teclas que quieras. Retroceso para borrar, Esc para cancelar.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e57e2f78..000006a3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1668,7 +1668,19 @@ }, "bundled": "Inclus", "bundledHint": "Livré avec WaveFlow — désactivez-le pour le masquer.", - "optionsAria": "Options de {{name}}" + "optionsAria": "Options de {{name}}", + "options": { + "saved": "Enregistré", + "secretStored": "Enregistré — collez pour remplacer", + "secretClear": "Effacer" + }, + "attention": { + "authRequired": { + "title": "{{name}} : identifiant refusé", + "body": "Le cookie ou le jeton enregistré a expiré ou a été révoqué. Collez-en un nouveau dans Paramètres → Extensions." + }, + "dismiss": "Fermer" + } }, "categoryNavLabel": "Catégories des paramètres", "appearance": { diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 4b382032..b2ce83a7 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -1665,7 +1665,19 @@ }, "bundled": "अंतर्निहित", "bundledHint": "WaveFlow के साथ शामिल — छुपाने के लिए अक्षम करें।", - "optionsAria": "{{name}} के विकल्प" + "optionsAria": "{{name}} के विकल्प", + "options": { + "saved": "सहेजा गया", + "secretStored": "सहेजा गया — बदलने के लिए पेस्ट करें", + "secretClear": "हटाएँ" + }, + "attention": { + "authRequired": { + "title": "{{name}}: साइन-इन अस्वीकार", + "body": "सहेजी गई कुकी या टोकन की अवधि समाप्त हो गई है या उसे रद्द कर दिया गया है। सेटिंग्स → एक्सटेंशन में नया पेस्ट करें।" + }, + "dismiss": "बंद करें" + } }, "shortcuts": { "subtitle": "किसी शॉर्टकट पर क्लिक करें फिर अपनी इच्छित कुंजी संयोजन दबाएँ। साफ़ करने के लिए Backspace, रद्द करने के लिए Escape।", diff --git a/src/i18n/locales/id.json b/src/i18n/locales/id.json index de0f9cc9..efb122f1 100644 --- a/src/i18n/locales/id.json +++ b/src/i18n/locales/id.json @@ -1665,7 +1665,19 @@ }, "bundled": "Bawaan", "bundledHint": "Disertakan dengan WaveFlow — nonaktifkan untuk menyembunyikannya.", - "optionsAria": "Opsi untuk {{name}}" + "optionsAria": "Opsi untuk {{name}}", + "options": { + "saved": "Tersimpan", + "secretStored": "Tersimpan — tempel untuk mengganti", + "secretClear": "Hapus" + }, + "attention": { + "authRequired": { + "title": "{{name}}: masuk ditolak", + "body": "Cookie atau token yang tersimpan telah kedaluwarsa atau dicabut. Tempel yang baru di Pengaturan → Ekstensi." + }, + "dismiss": "Tutup" + } }, "shortcuts": { "subtitle": "Klik sebuah pintasan lalu tekan kombinasi tombol yang kamu inginkan. Backspace untuk menghapus, Esc untuk membatalkan.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4c596da3..c0aa3078 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1668,7 +1668,19 @@ }, "bundled": "Integrato", "bundledHint": "Incluso in WaveFlow — disattivalo per nasconderlo.", - "optionsAria": "Opzioni di {{name}}" + "optionsAria": "Opzioni di {{name}}", + "options": { + "saved": "Salvato", + "secretStored": "Salvato — incolla per sostituire", + "secretClear": "Cancella" + }, + "attention": { + "authRequired": { + "title": "{{name}}: accesso rifiutato", + "body": "Il cookie o il token salvato è scaduto o è stato revocato. Incollane uno nuovo in Impostazioni → Estensioni." + }, + "dismiss": "Chiudi" + } }, "shortcuts": { "subtitle": "Clicca su una scorciatoia e premi la combinazione di tasti desiderata. Backspace per cancellare, Esc per annullare.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2cc150b2..fa9ecbe5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1665,7 +1665,19 @@ }, "bundled": "組み込み", "bundledHint": "WaveFlow に同梱されています — 非表示にするには無効にしてください。", - "optionsAria": "{{name}} のオプション" + "optionsAria": "{{name}} のオプション", + "options": { + "saved": "保存しました", + "secretStored": "保存済み — 貼り付けて置き換え", + "secretClear": "消去" + }, + "attention": { + "authRequired": { + "title": "{{name}}: ログインが拒否されました", + "body": "保存されている Cookie またはトークンの有効期限が切れたか、取り消されました。設定 → 拡張機能で新しいものを貼り付けてください。" + }, + "dismiss": "閉じる" + } }, "integrations": { "lastfm": { diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 6d87a6fa..47871b6a 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1665,7 +1665,19 @@ }, "bundled": "내장", "bundledHint": "WaveFlow에 포함되어 있습니다 — 숨기려면 비활성화하세요.", - "optionsAria": "{{name}} 옵션" + "optionsAria": "{{name}} 옵션", + "options": { + "saved": "저장됨", + "secretStored": "저장됨 — 붙여넣어 교체", + "secretClear": "지우기" + }, + "attention": { + "authRequired": { + "title": "{{name}}: 로그인이 거부됨", + "body": "저장된 쿠키 또는 토큰이 만료되었거나 취소되었습니다. 설정 → 확장 기능에서 새 값을 붙여넣으세요." + }, + "dismiss": "닫기" + } }, "shortcuts": { "subtitle": "단축키를 클릭한 후 원하는 키 조합을 누르세요. 지우려면 Backspace, 취소하려면 Escape를 누르세요.", diff --git a/src/i18n/locales/nl.json b/src/i18n/locales/nl.json index f3cb1d21..1aa9cf89 100644 --- a/src/i18n/locales/nl.json +++ b/src/i18n/locales/nl.json @@ -1665,7 +1665,19 @@ }, "bundled": "Ingebouwd", "bundledHint": "Meegeleverd met WaveFlow — schakel het uit om het te verbergen.", - "optionsAria": "Opties voor {{name}}" + "optionsAria": "Opties voor {{name}}", + "options": { + "saved": "Opgeslagen", + "secretStored": "Opgeslagen — plak om te vervangen", + "secretClear": "Wissen" + }, + "attention": { + "authRequired": { + "title": "{{name}}: aanmelding geweigerd", + "body": "De opgeslagen cookie of token is verlopen of ingetrokken. Plak een nieuwe in Instellingen → Extensies." + }, + "dismiss": "Sluiten" + } }, "shortcuts": { "subtitle": "Klik op een sneltoets en druk vervolgens op de gewenste toetscombinatie. Backspace om te wissen, Esc om te annuleren.", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index a906b62e..59df67ce 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -1669,7 +1669,19 @@ }, "bundled": "Integrado", "bundledHint": "Incluído com o WaveFlow — desative para ocultá-lo.", - "optionsAria": "Opções de {{name}}" + "optionsAria": "Opções de {{name}}", + "options": { + "saved": "Salvo", + "secretStored": "Salvo — cole para substituir", + "secretClear": "Limpar" + }, + "attention": { + "authRequired": { + "title": "{{name}}: acesso recusado", + "body": "O cookie ou o token salvo expirou ou foi revogado. Cole um novo em Configurações → Extensões." + }, + "dismiss": "Fechar" + } }, "shortcuts": { "subtitle": "Clique em um atalho e pressione a combinação de teclas que você quer. Backspace para limpar, Esc para cancelar.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 81bfb5ea..2010e6bb 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1669,7 +1669,19 @@ }, "bundled": "Integrado", "bundledHint": "Incluído no WaveFlow — desativa-o para o ocultar.", - "optionsAria": "Opções de {{name}}" + "optionsAria": "Opções de {{name}}", + "options": { + "saved": "Guardado", + "secretStored": "Guardado — cole para substituir", + "secretClear": "Limpar" + }, + "attention": { + "authRequired": { + "title": "{{name}}: acesso recusado", + "body": "O cookie ou o token guardado expirou ou foi revogado. Cole um novo em Definições → Extensões." + }, + "dismiss": "Fechar" + } }, "shortcuts": { "subtitle": "Clica num atalho e prime a combinação de teclas que desejas. Backspace para limpar, Escape para cancelar.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 82116110..7b313998 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1769,7 +1769,19 @@ }, "bundled": "Встроенный", "bundledHint": "Поставляется с WaveFlow — отключите, чтобы скрыть.", - "optionsAria": "Настройки {{name}}" + "optionsAria": "Настройки {{name}}", + "options": { + "saved": "Сохранено", + "secretStored": "Сохранено — вставьте, чтобы заменить", + "secretClear": "Очистить" + }, + "attention": { + "authRequired": { + "title": "{{name}}: вход отклонён", + "body": "Сохранённый cookie или токен истёк или был отозван. Вставьте новый в Настройках → Расширения." + }, + "dismiss": "Закрыть" + } }, "shortcuts": { "subtitle": "Нажмите на сочетание клавиш, затем введите нужную комбинацию. Backspace — очистить, Esc — отменить.", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index fb8c46df..929e072a 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1665,7 +1665,19 @@ }, "bundled": "Yerleşik", "bundledHint": "WaveFlow ile birlikte gelir — gizlemek için devre dışı bırak.", - "optionsAria": "{{name}} seçenekleri" + "optionsAria": "{{name}} seçenekleri", + "options": { + "saved": "Kaydedildi", + "secretStored": "Kaydedildi — değiştirmek için yapıştırın", + "secretClear": "Temizle" + }, + "attention": { + "authRequired": { + "title": "{{name}}: oturum reddedildi", + "body": "Kaydedilen çerez veya belirtecin süresi doldu ya da iptal edildi. Ayarlar → Eklentiler bölümüne yenisini yapıştırın." + }, + "dismiss": "Kapat" + } }, "shortcuts": { "subtitle": "Bir kısayola tıkla ve ardından istediğin tuş kombinasyonuna bas. Silmek için Backspace, iptal için Esc.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 58196250..053fdc6a 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1665,7 +1665,19 @@ }, "bundled": "内置", "bundledHint": "随 WaveFlow 提供——禁用以隐藏。", - "optionsAria": "{{name}} 的选项" + "optionsAria": "{{name}} 的选项", + "options": { + "saved": "已保存", + "secretStored": "已保存 — 粘贴以替换", + "secretClear": "清除" + }, + "attention": { + "authRequired": { + "title": "{{name}}:登录被拒绝", + "body": "已保存的 Cookie 或令牌已过期或被撤销。请在 设置 → 扩展 中粘贴新的值。" + }, + "dismiss": "关闭" + } }, "integrations": { "lastfm": { diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index f8db8428..2d466ae8 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1665,7 +1665,19 @@ }, "bundled": "內建", "bundledHint": "隨 WaveFlow 提供——停用以隱藏。", - "optionsAria": "{{name}} 的選項" + "optionsAria": "{{name}} 的選項", + "options": { + "saved": "已儲存", + "secretStored": "已儲存 — 貼上以取代", + "secretClear": "清除" + }, + "attention": { + "authRequired": { + "title": "{{name}}:登入遭拒", + "body": "已儲存的 Cookie 或權杖已過期或遭撤銷。請在 設定 → 擴充功能 中貼上新的值。" + }, + "dismiss": "關閉" + } }, "integrations": { "lastfm": { diff --git a/src/lib/tauri/plugins.ts b/src/lib/tauri/plugins.ts index 64e3695e..9bd71969 100644 --- a/src/lib/tauri/plugins.ts +++ b/src/lib/tauri/plugins.ts @@ -381,8 +381,13 @@ export interface PluginOption { choices: string[]; /** Manifest-authored: a plain string, or a `{ lang: text }` map. */ description: LocalizedText | null; - /** Current stored value; `null` = unset (the plugin uses `default`). */ + /** Current stored value; `null` = unset (the plugin uses `default`). + * Always `null` for a sensitive option — see `isSet`. */ value: string | null; + /** A credential (cookie, token): masked, its value never sent here. */ + sensitive: boolean; + /** Whether a value is stored — all the panel knows of a sensitive one. */ + isSet: boolean; } /** List a plugin's declared options merged with the user's current values. */