fix(clipboard): route writes through native plugin #190

This commit is contained in:
Darkatse 2026-08-18 19:25:48 -04:00
parent ae9b24deab
commit 6c0f7359d9
14 changed files with 291 additions and 16 deletions

View file

@ -257,7 +257,9 @@ Android AI 生成使用任务级 `dataSync` Foreground Service。Rust `ChatCompl
以下问题仅在 Android 旧 WebView 上高概率出现,桌面端通常不复现。
### 7.1 `*.at is not a function`
### 7.1 JavaScript 运行时兼容
#### `*.at is not a function`
现象:
@ -273,7 +275,13 @@ Android AI 生成使用任务级 `dataSync` Foreground Service。Rust `ChatCompl
- 在 Tauri mobile 启动期安装运行时兼容层:
- 实现:`src/tauri/main/compat/mobile/mobile-runtime-compat.js`
- 入口:`src/tauri/main/bootstrap.js`(仅 Android/iOS UA)
- 行为:仅补齐缺失 API,且只执行一次;桌面端/移动端 Web 不启用。
- 行为:基础 API 仅在缺失时补齐,且只执行一次;桌面端/移动端 Web 不启用。
#### Web Clipboard 写入被拒绝
Android WebView 可能暴露 `navigator.clipboard.writeText()`,却在调用时以 `NotAllowedError` 拒绝写入。TauriTavern 第一方复制在所有平台统一走 `writeClipboardText()`;Android 兼容层只把上游 Web Clipboard 的 `writeText` 映射到同一原生写入器,并保留 Clipboard 对象上的其他方法。
原生侧只授予 `clipboard-manager:allow-write-text`。写入剪贴板不需要 Android manifest 或运行时权限,也不开放读取能力;失败直接返回调用方,不静默回退。
### 7.2 插件面板样式大面积失效(如 `TH-custom-tailwind` 布局错乱)

View file

@ -244,7 +244,7 @@ TauriTavern 自有的新 UI 可以作为 SillyTavern first-party extension 挂
- 实现位置:`src/tauri/main/compat/mobile/mobile-runtime-compat.js`。
- 入口:`src/tauri/main/bootstrap.js` 中安装(仅 Tauri mobile)。
- 行为:仅补齐缺失 API,且只执行一次。
- 行为:基础 API 仅在缺失时补齐;Android 的 Web Clipboard 写入统一映射到原生写入器;整体只执行一次。
- 当前按需补齐:
- `Array.prototype.at`
- `String.prototype.at`
@ -253,8 +253,9 @@ TauriTavern 自有的新 UI 可以作为 SillyTavern first-party extension 挂
- `Array.prototype.toSorted`
- `Array.prototype.toReversed`
- `Object.hasOwn`
- `navigator.clipboard.writeText`(仅 Android;保留 Clipboard 对象上的其他方法)
该策略用于修复移动端第三方插件在初始化阶段出现的 `TypeError: *.at is not a function`。
该策略用于修复移动端第三方插件在初始化阶段出现的 `TypeError: *.at is not a function`,以及 Android WebView 拒绝 Web Clipboard 写入的问题。TauriTavern 第一方代码在所有平台直接使用 `src/tauri-bridge.js` 的原生写入器;宿主只授予 `clipboard-manager:allow-write-text`,不授予剪贴板读取能力,也不需要申请操作系统运行时权限。
#### 7.7.2 CSS `@layer` 降级(Android 旧 WebView)
@ -291,6 +292,8 @@ TauriTavern 自有的新 UI 可以作为 SillyTavern first-party extension 挂
- 若看到 `*.at is not a function`:
- 检查是否为 Tauri mobile 会话,并确认 `window.__TAURITAVERN_MOBILE_RUNTIME_COMPAT__ === true`。
- 若 Android 复制失败:
- 检查 `plugin:clipboard-manager|write_text` invoke 的拒绝原因;该链路不会静默回退到 Web Clipboard。
- 若插件样式错乱但 CSS 已成功请求:
- 优先检查是否命中 `@layer` 降级分支;
- 关注 `resolveStylesheetUrl()` 是否返回带 `ttCompat=layer` 的 URL。
@ -372,7 +375,7 @@ TauriTavern 自有的新 UI 可以作为 SillyTavern first-party extension 挂
- 常用导出命令:
- `window.__TAURITAVERN_PERF__.downloadReport()` 下载 JSON(便于交给 AI 分析)
- `window.__TAURITAVERN_PERF__.exportJson({ includeResources: true })` 直接拿到 JSON 字符串
- `await window.__TAURITAVERN_PERF__.copyReport()` 复制到剪贴板(若可用)
- `await window.__TAURITAVERN_PERF__.copyReport()` 通过原生写入器复制到剪贴板
- HUD 操作:拖动标题栏移动(位置持久化),点击标题栏展开/收起;桌面端可用 `Ctrl+Alt+P` 切换开关。
## 11. 工程守护(Guardrails + 类型检查)

View file

@ -326,10 +326,13 @@ header 名也可从 `window.__TAURITAVERN__?.traceHeader` 获取(用于避免
- `window.__TAURITAVERN_MOBILE_RUNTIME_COMPAT__`
- 覆盖移动端旧 WebView 的基础 polyfills(例如 `requestIdleCallback` / `cancelIdleCallback`)。
- Android 的 `navigator.clipboard.writeText()` 映射到宿主原生写入器,same-origin iframe 同样适用;Clipboard 对象上的其他方法保持不变。
- `window.__TAURITAVERN_MOBILE_OVERLAY_COMPAT__`
- `window.__TAURITAVERN_MOBILE_IFRAME_VIEWPORT_CONTRACT_BRIDGE__`:same-origin iframe 的 viewport/inset contract bridge(用于 `viewport-host` boundary;主要用于 debug/幂等安装)
- `window.__TAURITAVERN_MOBILE_WINDOW_OPEN_COMPAT__`:移动端外链 `window.open()` 通过系统浏览器打开(不创建应用内新窗口)
TauriTavern 第一方功能在所有平台直接使用同一个原生剪贴板写入器。该契约只授予 `clipboard-manager:allow-write-text`,不包含读取/清空/图片等权限;写入失败必须向调用方传播。
---
### 5.3 Dialog 兼容(Public in practice)

147
src-tauri/Cargo.lock generated
View file

@ -67,6 +67,27 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
version = "3.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
dependencies = [
"clipboard-win",
"image",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arc-swap"
version = "1.9.2"
@ -831,6 +852,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "clru"
version = "0.6.3"
@ -1480,7 +1510,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
dependencies = [
"bit-set",
"cssparser",
"foldhash",
"foldhash 0.2.0",
"html5ever",
"precomputed-hash",
"selectors",
@ -1726,6 +1756,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "esaxx-rs"
version = "0.1.10"
@ -1801,6 +1837,12 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@ -1888,6 +1930,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
@ -3349,6 +3397,15 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
@ -3357,7 +3414,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
"serde",
"serde_core",
]
@ -3370,7 +3427,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"foldhash 0.2.0",
"serde",
"serde_core",
]
@ -3792,6 +3849,7 @@ dependencies = [
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
]
@ -4839,6 +4897,7 @@ dependencies = [
"block2",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
]
@ -5115,6 +5174,16 @@ version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89722d987db848624cf8fca21245d59cf6382b01b4ca79ae70261cea5747495"
[[package]]
name = "os_pipe"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "outref"
version = "0.5.2"
@ -5213,6 +5282,17 @@ dependencies = [
"indexmap 2.14.0",
]
[[package]]
name = "petgraph"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"hashbrown 0.15.5",
"indexmap 2.14.0",
]
[[package]]
name = "phf"
version = "0.13.1"
@ -5501,7 +5581,7 @@ dependencies = [
"log",
"multimap",
"once_cell",
"petgraph",
"petgraph 0.7.1",
"prettyplease",
"prost",
"prost-types",
@ -7182,6 +7262,21 @@ dependencies = [
"thiserror 2.0.20",
]
[[package]]
name = "tauri-plugin-clipboard-manager"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
dependencies = [
"arboard",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.20",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.7.2"
@ -7457,6 +7552,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-barcode-scanner",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-notification",
@ -7564,6 +7660,20 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]]
name = "tiktoken-rs"
version = "0.12.0"
@ -7995,6 +8105,17 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "tree_magic_mini"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom 8.0.0",
"petgraph 0.8.3",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@ -9474,6 +9595,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wl-clipboard-rs"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
dependencies = [
"libc",
"log",
"os_pipe",
"rustix",
"thiserror 2.0.20",
"tree_magic_mini",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-protocols-wlr",
]
[[package]]
name = "write16"
version = "1.0.0"

View file

@ -24,6 +24,7 @@ tauri-plugin-opener = "2.5.4"
tauri-plugin-fs = "2.5.1"
tauri-plugin-notification = "2.3.3"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-clipboard-manager = "2.3.2"
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View file

@ -8,6 +8,7 @@
"permissions": [
"core:default",
"core:window:allow-destroy",
"clipboard-manager:allow-write-text",
"opener:default",
"notification:allow-notify",
"notification:allow-is-permission-granted",

View file

@ -21,6 +21,7 @@ pub(super) fn install<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::B
// Moving desktop/mobile plugins into setup would make capability availability
// depend on runtime initialization order instead of Builder construction.
let builder = builder
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())

View file

@ -1,6 +1,7 @@
import { POPUP_TYPE } from '../../popup.js';
import { translate } from '../../i18n.js';
import { trimFrontendLogEntriesInPlace } from '../../../tauri/main/services/dev-logging/frontend-log-retention.js';
import { writeClipboardText } from '../../../tauri-bridge.js';
import { openFullscreenTextViewer } from './text-viewer-popup.js';
import { runTaskOrPopup, showErrorPopup } from './setting-panel/popup-utils.js';
import { callTauriTavernPanelPopup } from './panel-popup.js';
@ -34,7 +35,7 @@ async function importDevLogsBundle() {
function createActions() {
return {
copyText: (text) => runTaskOrPopup(async () => {
await navigator.clipboard.writeText(String(text ?? ''));
await writeClipboardText(String(text ?? ''));
}),
openTextViewer: (options) => runTaskOrPopup(async () => {
await openFullscreenTextViewer(options);

View file

@ -2,6 +2,7 @@ import { callGenericPopup, POPUP_RESULT, POPUP_TYPE } from '../../../popup.js';
import { isMobile } from '../../../RossAscends-mods.js';
import { t, translate } from '../../../i18n.js';
import { scanQrCodeWithBackCancellation } from '../../../../tauri/main/services/barcode-scanner/barcode-scanner-service.js';
import { writeClipboardText } from '../../../../tauri-bridge.js';
import {
LAN_SYNC_DEVICES_CHANGED_EVENT,
SYNC_AUTOMATION_CHANGED_EVENT,
@ -508,7 +509,7 @@ async function editSyncScope(catalog, selection) {
function createSyncActions(client) {
return {
copyText: async (text) => {
await navigator.clipboard.writeText(String(text ?? ''));
await writeClipboardText(String(text ?? ''));
},
scanPairUri: () => scanQrCodeWithBackCancellation(),
reportError: (error) => showErrorPopup(error),

View file

@ -245,6 +245,10 @@ export async function openDialog(options = {}) {
return invokeWithHostNormalization('plugin:dialog|open', { options });
}
export function writeClipboardText(text) {
return invoke('plugin:clipboard-manager|write_text', { text });
}
function normalizeExternalUrl(url) {
const value = String(url instanceof URL ? url.href : url ?? '').trim();
if (!value) {

View file

@ -1,3 +1,6 @@
import { writeClipboardText } from '../../../../tauri-bridge.js';
import { isAndroidRuntime } from '../../../../scripts/util/mobile-runtime.js';
const COMPAT_KEY = '__TAURITAVERN_MOBILE_RUNTIME_COMPAT__';
function defineMissingMethod(target, key, implementation) {
@ -161,6 +164,28 @@ function hasOwnPolyfill(target, property) {
return Object.prototype.hasOwnProperty.call(Object(target), property);
}
function installAndroidClipboardWriteCompat(targetWindow) {
if (!isAndroidRuntime()) {
return;
}
const targetNavigator = targetWindow.navigator;
const clipboard = targetNavigator.clipboard || {};
// Android WebView exposes writeText but can reject it; replace only that method.
Object.defineProperty(clipboard, 'writeText', {
value: (text) => writeClipboardText(String(text)),
configurable: true,
writable: true,
});
if (!targetNavigator.clipboard) {
Object.defineProperty(targetNavigator, 'clipboard', {
value: clipboard,
configurable: true,
});
}
}
export function installMobileRuntimeCompat(targetWindow = window) {
if (!targetWindow) {
return;
@ -191,6 +216,7 @@ export function installMobileRuntimeCompat(targetWindow = window) {
console.warn('[TauriTavern] Mobile runtime compat installed a requestIdleCallback polyfill.');
}
installAndroidClipboardWriteCompat(targetWindow);
defineMissingMethod(targetWindow.Array?.prototype, 'at', atPolyfill);
defineMissingMethod(targetWindow.String?.prototype, 'at', atPolyfill);
defineMissingMethod(targetWindow.Array?.prototype, 'findLast', findLastPolyfill);

View file

@ -1,3 +1,5 @@
import { writeClipboardText } from '../../../tauri-bridge.js';
const GLOBAL_KEY = '__TAURITAVERN_PERF__';
const EMBEDDED_RUNTIME_KEY = '__TAURITAVERN_EMBEDDED_RUNTIME__';
const PANEL_RUNTIME_KEY = '__TAURITAVERN_PANEL_RUNTIME__';
@ -1528,13 +1530,9 @@ function createPerfController(options = {}) {
async function copyReport(options = {}) {
const json = exportJson(options);
const clipboard = navigator?.clipboard;
if (!clipboard || typeof clipboard.writeText !== 'function') {
return false;
}
try {
await clipboard.writeText(json);
await writeClipboardText(json);
return true;
} catch {
return false;

View file

@ -0,0 +1,81 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
let invokedCommand;
let invokedArgs;
global.window = {
__TAURI_RUNNING__: true,
__TAURI__: {
core: {
invoke: async (command, args) => {
invokedCommand = command;
invokedArgs = args;
},
},
},
};
const compatPath = path.join(
REPO_ROOT,
'src/tauri/main/compat/mobile/mobile-runtime-compat.js',
);
const { installMobileRuntimeCompat } = await import(pathToFileURL(compatPath).href);
function createTargetWindow(userAgent, clipboard) {
return {
navigator: { userAgent, clipboard },
Array,
String,
Object,
requestIdleCallback() {},
cancelIdleCallback() {},
};
}
function setRuntimeUserAgent(userAgent) {
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent },
configurable: true,
});
}
test('Android Web Clipboard writes through the native writer without replacing other methods', async () => {
setRuntimeUserAgent('Mozilla/5.0 (Linux; Android 15)');
const readText = async () => 'existing';
const clipboard = { readText, writeText: async () => assert.fail('used Web Clipboard') };
const targetWindow = createTargetWindow('Mozilla/5.0 (Linux; Android 15)', clipboard);
installMobileRuntimeCompat(targetWindow);
await targetWindow.navigator.clipboard.writeText(42);
assert.equal(targetWindow.navigator.clipboard.readText, readText);
assert.equal(invokedCommand, 'plugin:clipboard-manager|write_text');
assert.deepEqual(invokedArgs, { text: '42' });
});
test('non-Android Web Clipboard is left unchanged', () => {
setRuntimeUserAgent('Mozilla/5.0 (iPhone)');
const writeText = async () => {};
const targetWindow = createTargetWindow('Mozilla/5.0 (iPhone)', { writeText });
installMobileRuntimeCompat(targetWindow);
assert.equal(targetWindow.navigator.clipboard.writeText, writeText);
});
test('the main window capability grants only clipboard text writes', async () => {
const capability = JSON.parse(await readFile(
path.join(REPO_ROOT, 'src-tauri/crates/tauritavern/capabilities/default.json'),
'utf8',
));
const clipboardPermissions = capability.permissions.filter(
permission => typeof permission === 'string' && permission.startsWith('clipboard-manager:'),
);
assert.deepEqual(clipboardPermissions, ['clipboard-manager:allow-write-text']);
});

View file

@ -30,8 +30,16 @@ const {
updateTauriTavernSettings,
openDialog,
setDataRoot,
writeClipboardText,
} = await import(pathToFileURL(tauriBridgePath).href);
test('writeClipboardText invokes the native write-only clipboard command', async () => {
await writeClipboardText('ttsync://pair/example');
assert.equal(lastInvokedCommand, 'plugin:clipboard-manager|write_text');
assert.deepEqual(lastInvokedArgs, { text: 'ttsync://pair/example' });
});
test('local endpoint authorization bridge forwards the selected provider config', async () => {
const dto = {
chat_completion_source: 'custom',