feat(clipboard-manager): add support for writing secrets (#3376)

* feat(clipboard-manager): add support for writing secrets

add `write_secret` command that writes a text into the clipboard and
hints clipboard managers to not save the text.

uses `arboard`'s `SetExtLinux`, `SetExtWindows`, and `SetExtApple`
extensions for each platform.

* fix(clipboard-manager): require arboard 3.6

* chore(clipboard-manager): add license header to secret.rs

* fix(clipboard-manager): only exclude from monitoring on Windows

* refactor(clipboard-manager): replace ExcludeSecret trait with a function

* chore(clipboard-manager): drop redundant cfg(desktop) in desktop.rs

* refactor(clipboard-manager): share clipboard access in a helper

* feat(clipboard-manager): implement write_secret on Android and iOS

* docs(clipboard-manager): document writeSecret

* chore(clipboard-manager): add change file for writeSecret

* test(clipboard-manager): cover writeSecret in e2e

---------

Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
Umut DağandLucas Nogueira authored and GitHub committed 2026-10-13 17:37:11 -03:00
1 parent 1ab930465f
commit 57297f3fb1
21 files changed
+247 -72

No files matched your search

+6
View File
@@ -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.
Generated
+3 -3
View File
@@ -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",
]
@@ -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",
+8
View File
@@ -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}
/>
<button class="btn" type="button" on:click={writeText}>Write</button>
<button class="btn" type="button" on:click={writeSecret}>Write Secret</button>
<button class="btn" type="button" on:click={writeImage}>Pick Image</button>
<button class="btn" type="button" on:click={read}>Read</button>
</div>
+1 -1
View File
@@ -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). |
@@ -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) => {
+2 -1
View File
@@ -30,7 +30,8 @@ const commonSurface: Surface<CommonPluginApi> = {
'writeHtml',
'clear',
'readImage',
'writeImage'
'writeImage',
'writeSecret'
],
deepLink: [
'getCurrent',
+1 -1
View File
@@ -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"] }
@@ -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()) {
+1 -1
View File
@@ -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__})}
+1
View File
@@ -9,6 +9,7 @@ const COMMANDS: &[&str] = &[
"read_image",
"write_html",
"clear",
"write_secret",
];
fn main() {
+32 -1
View File
@@ -156,4 +156,35 @@ async function clear(): Promise<void> {
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<void> {
await invoke('plugin:clipboard-manager|write_secret', { text })
}
export {
writeText,
readText,
writeHtml,
clear,
readImage,
writeImage,
writeSecret
}
@@ -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 {
@@ -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"]
@@ -148,6 +148,32 @@ Denies the write_image command without any pre-configured scope.
<tr>
<td>
`clipboard-manager:allow-write-secret`
</td>
<td>
Enables the write_secret command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`clipboard-manager:deny-write-secret`
</td>
<td>
Denies the write_secret command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`clipboard-manager:allow-write-text`
</td>
@@ -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",
@@ -78,3 +78,12 @@ pub(crate) async fn clear<R: Runtime>(
) -> Result<()> {
clipboard.clear()
}
#[command]
pub(crate) async fn write_secret<R: Runtime>(
_app: AppHandle<R>,
clipboard: State<'_, Clipboard<R>>,
text: &str,
) -> Result<()> {
clipboard.write_secret(text)
}
+32 -63
View File
@@ -28,6 +28,16 @@ pub struct Clipboard<R: Runtime> {
}
impl<R: Runtime> Clipboard<R> {
fn with_clipboard<T>(
&self,
f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
) -> crate::Result<T> {
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<R: Runtime> Clipboard<R> {
/// Returns [`crate::Error::Clipboard`] if the clipboard could not be initialized or the
/// underlying [`arboard`] operation fails.
pub fn write_text<'a, T: Into<Cow<'a, str>>>(&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<R: Runtime> Clipboard<R> {
/// 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<String> {
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<R: Runtime> Clipboard<R> {
html: T,
alt_text: Option<T>,
) -> 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<R: Runtime> Clipboard<R> {
/// 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<Image<'_>> {
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<R: Runtime> Clipboard<R> {
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<Cow<'a, str>>>(&self, text: T) -> crate::Result<()> {
self.with_clipboard(|c| crate::secret::exclude_secret(c.set()).text(text))
}
}
+5 -1
View File
@@ -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<R: Runtime>() -> TauriPlugin<R> {
commands::read_image,
commands::write_image,
commands::write_html,
commands::clear
commands::clear,
commands::write_secret,
])
.setup(|app, api| {
#[cfg(mobile)]
+21
View File
@@ -123,6 +123,22 @@ impl<R: Runtime> Clipboard<R> {
))
}
/// 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<Cow<'a, str>>>(&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<String>, text: String },
}
#[derive(Debug, Serialize)]
struct WriteSecretArgs {
text: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
enum ClipboardContents {
+23
View File
@@ -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();
}