diff --git a/.changes/clipboard-write-secret.md b/.changes/clipboard-write-secret.md new file mode 100644 index 000000000..cd82c7a49 --- /dev/null +++ b/.changes/clipboard-write-secret.md @@ -0,0 +1,6 @@ +--- +clipboard-manager: minor +clipboard-manager-js: minor +--- + +Added `writeSecret` (`Clipboard::write_secret` in Rust) to write sensitive text, such as passwords, to the clipboard. Clipboard managers are asked to keep it out of their history on Windows, macOS and Linux. On Android the clip is flagged as sensitive, and on iOS it is not synced to other devices. diff --git a/Cargo.lock b/Cargo.lock index fc861169e..15f3e418a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -297,9 +297,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.5.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1df21f715862ede32a0c525ce2ca4d52626bb0007f8c18b87a384503ac33e70" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", "image", @@ -311,7 +311,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json index 56c25550e..518fc1248 100644 --- a/examples/api/src-tauri/capabilities/base.json +++ b/examples/api/src-tauri/capabilities/base.json @@ -90,6 +90,7 @@ "clipboard-manager:allow-write-image", "clipboard-manager:allow-write-html", "clipboard-manager:allow-clear", + "clipboard-manager:allow-write-secret", "fs:default", "fs:read-meta", "fs:allow-open", diff --git a/examples/api/src/views/Clipboard.svelte b/examples/api/src/views/Clipboard.svelte index 2bc0fcc33..08bb83bd1 100644 --- a/examples/api/src/views/Clipboard.svelte +++ b/examples/api/src/views/Clipboard.svelte @@ -35,6 +35,13 @@ } } + function writeSecret() { + clipboard.writeSecret(text).then(() => { + onMessage("Wrote secret to the clipboard") + }) + .catch(onMessage) + } + async function read() { try { const image = await clipboard.readImage() @@ -63,6 +70,7 @@ bind:value={text} /> + diff --git a/packages/api-e2e/README.md b/packages/api-e2e/README.md index abc98972e..947dd00e2 100644 --- a/packages/api-e2e/README.md +++ b/packages/api-e2e/README.md @@ -68,7 +68,7 @@ skipped on desktop, and the rest run everywhere with the odd test gated. | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | | `autostart` | enable/disable/isEnabled, restoring the host's original state. | Skipped — desktop-only plugin. | | `cli` | `getMatches` shape for an argument-less launch. | Skipped — desktop-only plugin. | -| `clipboard-manager` | text, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. | +| `clipboard-manager` | text, secret, HTML and image round-trips, `clear`, error paths. | Text only; HTML and images are unsupported there. | | `deep-link` | `getCurrent` on a plain launch, `onOpenUrl` delivery, runtime `register`/`isRegistered`/`unregister` on Linux and Windows. | Only that runtime registration is unsupported. | | `dialog` | Only that the API is registered — every dialog blocks on native UI the driver cannot operate. | Same. | | `fs` | read/write/stat/copy/rename/remove/truncate, `FileHandle`, line iteration, watchers, scope. | All but the watchers (`fs:allow-watch` is desktop-only here). | diff --git a/packages/api-e2e/test/specs/clipboard-manager.spec.ts b/packages/api-e2e/test/specs/clipboard-manager.spec.ts index f5bf0e428..fad56bb02 100644 --- a/packages/api-e2e/test/specs/clipboard-manager.spec.ts +++ b/packages/api-e2e/test/specs/clipboard-manager.spec.ts @@ -34,6 +34,18 @@ describePlugin('clipboard-manager', () => { ).toBe('second') }) + it('writeSecret writes text the app can read back', async () => { + // the history/sync exclusion hints aren't observable from the app itself + const text = 'secret from e2e — ✓' + expect( + await tauri(async (api, text) => { + await api.clipboardManager.writeText('not the secret') + await api.clipboardManager.writeSecret(text) + return api.clipboardManager.readText() + }, text) + ).toBe(text) + }) + itDesktop('writeHtml exposes the alt text as plain text', async () => { expect( await tauri(async (api) => { diff --git a/packages/api-e2e/test/specs/plugins.spec.ts b/packages/api-e2e/test/specs/plugins.spec.ts index bfa8a7a0a..54a061a19 100644 --- a/packages/api-e2e/test/specs/plugins.spec.ts +++ b/packages/api-e2e/test/specs/plugins.spec.ts @@ -30,7 +30,8 @@ const commonSurface: Surface = { 'writeHtml', 'clear', 'readImage', - 'writeImage' + 'writeImage', + 'writeSecret' ], deepLink: [ 'getCurrent', diff --git a/plugins/clipboard-manager/Cargo.toml b/plugins/clipboard-manager/Cargo.toml index 483d96a63..38d8ecc3c 100644 --- a/plugins/clipboard-manager/Cargo.toml +++ b/plugins/clipboard-manager/Cargo.toml @@ -42,4 +42,4 @@ thiserror = { workspace = true } tauri = { workspace = true, features = ["wry"] } [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\", target_os = \"dragonfly\", target_os = \"freebsd\", target_os = \"openbsd\", target_os = \"netbsd\"))".dependencies] -arboard = { version = "3", features = ["wayland-data-control"] } +arboard = { version = "3.6", features = ["wayland-data-control"] } diff --git a/plugins/clipboard-manager/android/src/main/java/ClipboardPlugin.kt b/plugins/clipboard-manager/android/src/main/java/ClipboardPlugin.kt index ebb931b40..966430d53 100644 --- a/plugins/clipboard-manager/android/src/main/java/ClipboardPlugin.kt +++ b/plugins/clipboard-manager/android/src/main/java/ClipboardPlugin.kt @@ -10,6 +10,7 @@ import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.os.Build +import android.os.PersistableBundle import app.tauri.annotation.Command import app.tauri.annotation.InvokeArg import app.tauri.annotation.TauriPlugin @@ -37,6 +38,11 @@ sealed class WriteOptions { } } +@InvokeArg +class WriteSecretArgs { + lateinit var text: String +} + @JsonSerialize(using = ReadClipDataSerializer::class) sealed class ReadClipData { class PlainText: ReadClipData() { @@ -107,6 +113,25 @@ class ClipboardPlugin(private val activity: Activity) : Plugin(activity) { invoke.resolve() } + @Command + fun writeSecret(invoke: Invoke) { + val args = invoke.parseArgs(WriteSecretArgs::class.java) + + val clipData = ClipData.newPlainText(null, args.text) + val isSensitiveKey = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ClipDescription.EXTRA_IS_SENSITIVE + } else { + "android.content.extra.IS_SENSITIVE" + } + clipData.description.extras = PersistableBundle().apply { + putBoolean(isSensitiveKey, true) + } + + manager.setPrimaryClip(clipData) + + invoke.resolve() + } + @Command fun readText(invoke: Invoke) { val data = if (manager.hasPrimaryClip()) { diff --git a/plugins/clipboard-manager/api-iife.js b/plugins/clipboard-manager/api-iife.js index addfa4a7a..79885b154 100644 --- a/plugins/clipboard-manager/api-iife.js +++ b/plugins/clipboard-manager/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_CLIPBOARD_MANAGER__=function(a,e,n){"use strict";return a.clear=async function(){await e.invoke("plugin:clipboard-manager|clear")},a.readImage=async function(){return await e.invoke("plugin:clipboard-manager|read_image").then(a=>new n.Image(a))},a.readText=async function(){return await e.invoke("plugin:clipboard-manager|read_text")},a.writeHtml=async function(a,n){await e.invoke("plugin:clipboard-manager|write_html",{html:a,altText:n})},a.writeImage=async function(a){await e.invoke("plugin:clipboard-manager|write_image",{image:n.transformImage(a)})},a.writeText=async function(a,n){await e.invoke("plugin:clipboard-manager|write_text",{label:n?.label,text:a})},a}({},window.__TAURI__.core,window.__TAURI__.image);Object.defineProperty(window.__TAURI__,"clipboardManager",{value:__TAURI_PLUGIN_CLIPBOARD_MANAGER__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_CLIPBOARD_MANAGER__=function(a,e,i){"use strict";return a.clear=async function(){await e.invoke("plugin:clipboard-manager|clear")},a.readImage=async function(){return await e.invoke("plugin:clipboard-manager|read_image").then(a=>new i.Image(a))},a.readText=async function(){return await e.invoke("plugin:clipboard-manager|read_text")},a.writeHtml=async function(a,i){await e.invoke("plugin:clipboard-manager|write_html",{html:a,altText:i})},a.writeImage=async function(a){await e.invoke("plugin:clipboard-manager|write_image",{image:i.transformImage(a)})},a.writeSecret=async function(a){await e.invoke("plugin:clipboard-manager|write_secret",{text:a})},a.writeText=async function(a,i){await e.invoke("plugin:clipboard-manager|write_text",{label:i?.label,text:a})},a}({},window.__TAURI__.core,window.__TAURI__.image);Object.defineProperty(window.__TAURI__,"clipboardManager",{value:__TAURI_PLUGIN_CLIPBOARD_MANAGER__})} diff --git a/plugins/clipboard-manager/build.rs b/plugins/clipboard-manager/build.rs index fa3d2b3d8..64ede82dd 100644 --- a/plugins/clipboard-manager/build.rs +++ b/plugins/clipboard-manager/build.rs @@ -9,6 +9,7 @@ const COMMANDS: &[&str] = &[ "read_image", "write_html", "clear", + "write_secret", ]; fn main() { diff --git a/plugins/clipboard-manager/guest-js/index.ts b/plugins/clipboard-manager/guest-js/index.ts index bea789ee1..80855807d 100644 --- a/plugins/clipboard-manager/guest-js/index.ts +++ b/plugins/clipboard-manager/guest-js/index.ts @@ -156,4 +156,35 @@ async function clear(): Promise { await invoke('plugin:clipboard-manager|clear') } -export { writeText, readText, writeHtml, clear, readImage, writeImage } +/** + * Writes sensitive plain text, such as a password, to the clipboard. + * + * The text is marked so the system doesn't keep or share it: on Windows it is excluded from + * clipboard history and cloud sync, on macOS and Linux clipboard managers are asked to skip it, + * on Android it is flagged as sensitive so the clipboard preview hides it, + * and on iOS it is not synced to other devices. + * + * @example + * ```typescript + * import { writeSecret } from '@tauri-apps/plugin-clipboard-manager'; + * await writeSecret('my-password'); + * ``` + * + * @param text The plain text to write to the clipboard. + * @returns A promise indicating the success or failure of the operation. + * + * @since 2.5.0 + */ +async function writeSecret(text: string): Promise { + await invoke('plugin:clipboard-manager|write_secret', { text }) +} + +export { + writeText, + readText, + writeHtml, + clear, + readImage, + writeImage, + writeSecret +} diff --git a/plugins/clipboard-manager/ios/Sources/ClipboardPlugin.swift b/plugins/clipboard-manager/ios/Sources/ClipboardPlugin.swift index 11398ef6e..f27744bd5 100644 --- a/plugins/clipboard-manager/ios/Sources/ClipboardPlugin.swift +++ b/plugins/clipboard-manager/ios/Sources/ClipboardPlugin.swift @@ -11,6 +11,10 @@ enum WriteOptions: Codable { case plainText(text: String) } +struct WriteSecretArgs: Decodable { + let text: String +} + enum ReadClipData: Codable { case plainText(text: String) } @@ -30,6 +34,15 @@ class ClipboardPlugin: Plugin { } + @objc public func writeSecret(_ invoke: Invoke) throws { + let args = try invoke.parseArgs(WriteSecretArgs.self) + UIPasteboard.general.setItems( + [["public.utf8-plain-text": args.text]], + options: [.localOnly: true] + ) + invoke.resolve() + } + @objc public func readText(_ invoke: Invoke) throws { let clipboard = UIPasteboard.general if let text = clipboard.string { diff --git a/plugins/clipboard-manager/permissions/autogenerated/commands/write_secret.toml b/plugins/clipboard-manager/permissions/autogenerated/commands/write_secret.toml new file mode 100644 index 000000000..6c28172a0 --- /dev/null +++ b/plugins/clipboard-manager/permissions/autogenerated/commands/write_secret.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-write-secret" +description = "Enables the write_secret command without any pre-configured scope." +commands.allow = ["write_secret"] + +[[permission]] +identifier = "deny-write-secret" +description = "Denies the write_secret command without any pre-configured scope." +commands.deny = ["write_secret"] diff --git a/plugins/clipboard-manager/permissions/autogenerated/reference.md b/plugins/clipboard-manager/permissions/autogenerated/reference.md index 98a7fa961..d4c2430ff 100644 --- a/plugins/clipboard-manager/permissions/autogenerated/reference.md +++ b/plugins/clipboard-manager/permissions/autogenerated/reference.md @@ -148,6 +148,32 @@ Denies the write_image command without any pre-configured scope. +`clipboard-manager:allow-write-secret` + + + + +Enables the write_secret command without any pre-configured scope. + + + + + + + +`clipboard-manager:deny-write-secret` + + + + +Denies the write_secret command without any pre-configured scope. + + + + + + + `clipboard-manager:allow-write-text` diff --git a/plugins/clipboard-manager/permissions/schemas/schema.json b/plugins/clipboard-manager/permissions/schemas/schema.json index 891c6f0d8..ee2447033 100644 --- a/plugins/clipboard-manager/permissions/schemas/schema.json +++ b/plugins/clipboard-manager/permissions/schemas/schema.json @@ -354,6 +354,18 @@ "const": "deny-write-image", "markdownDescription": "Denies the write_image command without any pre-configured scope." }, + { + "description": "Enables the write_secret command without any pre-configured scope.", + "type": "string", + "const": "allow-write-secret", + "markdownDescription": "Enables the write_secret command without any pre-configured scope." + }, + { + "description": "Denies the write_secret command without any pre-configured scope.", + "type": "string", + "const": "deny-write-secret", + "markdownDescription": "Denies the write_secret command without any pre-configured scope." + }, { "description": "Enables the write_text command without any pre-configured scope.", "type": "string", diff --git a/plugins/clipboard-manager/src/commands.rs b/plugins/clipboard-manager/src/commands.rs index a903e9844..5272972d6 100644 --- a/plugins/clipboard-manager/src/commands.rs +++ b/plugins/clipboard-manager/src/commands.rs @@ -78,3 +78,12 @@ pub(crate) async fn clear( ) -> Result<()> { clipboard.clear() } + +#[command] +pub(crate) async fn write_secret( + _app: AppHandle, + clipboard: State<'_, Clipboard>, + text: &str, +) -> Result<()> { + clipboard.write_secret(text) +} diff --git a/plugins/clipboard-manager/src/desktop.rs b/plugins/clipboard-manager/src/desktop.rs index 6cb1ca9b7..e0c4a020a 100644 --- a/plugins/clipboard-manager/src/desktop.rs +++ b/plugins/clipboard-manager/src/desktop.rs @@ -28,6 +28,16 @@ pub struct Clipboard { } impl Clipboard { + fn with_clipboard( + &self, + f: impl FnOnce(&mut arboard::Clipboard) -> Result, + ) -> crate::Result { + match &self.clipboard { + Ok(clipboard) => f(clipboard.lock().unwrap().as_mut().unwrap()).map_err(Into::into), + Err(e) => Err(crate::Error::Clipboard(e.to_string())), + } + } + /// Writes plain text to the system clipboard. /// /// # Errors @@ -35,16 +45,7 @@ impl Clipboard { /// Returns [`crate::Error::Clipboard`] if the clipboard could not be initialized or the /// underlying [`arboard`] operation fails. pub fn write_text<'a, T: Into>>(&self, text: T) -> crate::Result<()> { - match &self.clipboard { - Ok(clipboard) => clipboard - .lock() - .unwrap() - .as_mut() - .unwrap() - .set_text(text) - .map_err(Into::into), - Err(e) => Err(crate::Error::Clipboard(e.to_string())), - } + self.with_clipboard(|c| c.set_text(text)) } /// Writes an image to the system clipboard as RGBA data. @@ -54,31 +55,18 @@ impl Clipboard { /// Returns [`crate::Error::Clipboard`] if the clipboard could not be initialized or the /// underlying [`arboard`] operation fails. pub fn write_image(&self, image: &Image<'_>) -> crate::Result<()> { - match &self.clipboard { - Ok(clipboard) => clipboard - .lock() - .unwrap() - .as_mut() - .unwrap() - .set_image(ImageData { - bytes: Cow::Borrowed(image.rgba()), - width: image.width() as usize, - height: image.height() as usize, - }) - .map_err(Into::into), - Err(e) => Err(crate::Error::Clipboard(e.to_string())), - } + self.with_clipboard(|c| { + c.set_image(ImageData { + bytes: Cow::Borrowed(image.rgba()), + width: image.width() as usize, + height: image.height() as usize, + }) + }) } /// Warning: This method should not be used on the main thread! Otherwise the underlying libraries may deadlock on Linux, freezing the whole app, when trying to copy data copied from this app, for example if the user copies text from the WebView. pub fn read_text(&self) -> crate::Result { - match &self.clipboard { - Ok(clipboard) => { - let text = clipboard.lock().unwrap().as_mut().unwrap().get_text()?; - Ok(text) - } - Err(e) => Err(crate::Error::Clipboard(e.to_string())), - } + self.with_clipboard(|c| c.get_text()) } /// Writes HTML to the system clipboard, with an optional plain text fallback for targets @@ -93,16 +81,7 @@ impl Clipboard { html: T, alt_text: Option, ) -> crate::Result<()> { - match &self.clipboard { - Ok(clipboard) => clipboard - .lock() - .unwrap() - .as_mut() - .unwrap() - .set_html(html, alt_text) - .map_err(Into::into), - Err(e) => Err(crate::Error::Clipboard(e.to_string())), - } + self.with_clipboard(|c| c.set_html(html, alt_text)) } /// Clears the system clipboard. @@ -112,32 +91,17 @@ impl Clipboard { /// Returns [`crate::Error::Clipboard`] if the clipboard could not be initialized or the /// underlying [`arboard`] operation fails. pub fn clear(&self) -> crate::Result<()> { - match &self.clipboard { - Ok(clipboard) => clipboard - .lock() - .unwrap() - .as_mut() - .unwrap() - .clear() - .map_err(Into::into), - Err(e) => Err(crate::Error::Clipboard(e.to_string())), - } + self.with_clipboard(|c| c.clear()) } /// Warning: This method should not be used on the main thread! Otherwise the underlying libraries may deadlock on Linux, freezing the whole app, when trying to copy data copied from this app, for example if the user copies text from the WebView. pub fn read_image(&self) -> crate::Result> { - match &self.clipboard { - Ok(clipboard) => { - let image = clipboard.lock().unwrap().as_mut().unwrap().get_image()?; - let image = Image::new_owned( - image.bytes.to_vec(), - image.width as u32, - image.height as u32, - ); - Ok(image) - } - Err(e) => Err(crate::Error::Clipboard(e.to_string())), - } + let image = self.with_clipboard(|c| c.get_image())?; + Ok(Image::new_owned( + image.bytes.to_vec(), + image.width as u32, + image.height as u32, + )) } pub(crate) fn cleanup(&self) { @@ -145,4 +109,9 @@ impl Clipboard { clipboard.lock().unwrap().take(); } } + + /// This is the same as write_text but it will set hints using `arboard`'s `SetExt*` extensions so clipboard managers exclude the text from history. + pub fn write_secret<'a, T: Into>>(&self, text: T) -> crate::Result<()> { + self.with_clipboard(|c| crate::secret::exclude_secret(c.set()).text(text)) + } } diff --git a/plugins/clipboard-manager/src/lib.rs b/plugins/clipboard-manager/src/lib.rs index 024f6f593..b1e3714cc 100644 --- a/plugins/clipboard-manager/src/lib.rs +++ b/plugins/clipboard-manager/src/lib.rs @@ -22,6 +22,9 @@ mod mobile; mod commands; mod error; +#[cfg(desktop)] +mod secret; + pub use error::{Error, Result}; #[cfg(desktop)] @@ -50,7 +53,8 @@ pub fn init() -> TauriPlugin { commands::read_image, commands::write_image, commands::write_html, - commands::clear + commands::clear, + commands::write_secret, ]) .setup(|app, api| { #[cfg(mobile)] diff --git a/plugins/clipboard-manager/src/mobile.rs b/plugins/clipboard-manager/src/mobile.rs index bb71b6ce7..4f66961ce 100644 --- a/plugins/clipboard-manager/src/mobile.rs +++ b/plugins/clipboard-manager/src/mobile.rs @@ -123,6 +123,22 @@ impl Clipboard { )) } + /// Writes plain text to the system clipboard, marked as sensitive. + /// + /// On Android the clip is flagged with `ClipDescription.EXTRA_IS_SENSITIVE`, which hides it + /// from the clipboard preview. On iOS the text is written as local only, so it is not + /// synced to other devices through Universal Clipboard. + /// + /// # Errors + /// + /// Returns [`crate::Error::PluginInvoke`] if the underlying mobile plugin call fails. + pub fn write_secret<'a, T: Into>>(&self, text: T) -> crate::Result<()> { + let text = text.into().to_string(); + self.0 + .run_mobile_plugin("writeSecret", WriteSecretArgs { text }) + .map_err(Into::into) + } + /// Clears the system clipboard. /// /// On Android this only works on SDK 28 and above; on older versions the clipboard is @@ -142,6 +158,11 @@ enum ClipKind { PlainText { label: Option, text: String }, } +#[derive(Debug, Serialize)] +struct WriteSecretArgs { + text: String, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] enum ClipboardContents { diff --git a/plugins/clipboard-manager/src/secret.rs b/plugins/clipboard-manager/src/secret.rs new file mode 100644 index 000000000..0e4777db9 --- /dev/null +++ b/plugins/clipboard-manager/src/secret.rs @@ -0,0 +1,23 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +#[cfg(windows)] +use arboard::SetExtWindows; + +#[cfg(target_os = "macos")] +use arboard::SetExtApple; + +#[cfg(not(any(windows, target_os = "macos")))] +use arboard::SetExtLinux; + +/// Hints clipboard managers to exclude the data from history. +/// +/// On Windows, it calls [`arboard::SetExtWindows::exclude_from_monitoring`], which also covers history and cloud sync. +/// On macOS and Linux, it calls `exclude_from_history`. +pub(crate) fn exclude_secret(set: arboard::Set<'_>) -> arboard::Set<'_> { + #[cfg(windows)] + return set.exclude_from_monitoring(); + #[cfg(not(windows))] + return set.exclude_from_history(); +}