/*
* noVNC: HTML5 VNC client
* Clipboard management module
*/
import * as Log from '../core/util/logging.js';
import { l10n } from './localization.js';
const _ = l10n.get.bind(l10n);
class ClipboardManager {
constructor() {
this.rfb = null;
this.autoSync = false;
this.permissionGranted = false;
this.lastLocalText = null;
this.syncFocusHandler = null;
// Callbacks for UI updates
this.onStatusChange = null; // (status, message) => void
}
setRFB(rfb) {
this.rfb = rfb;
}
/**
* Initialize clipboard state based on permissions
* 根据权限状态初始化剪贴板自动同步
*
* 注意:默认开启自动同步,因为:
* 1. 这是更好的用户体验
* 2. Clipboard API 在用户手势(如点击、焦点切换)时才会真正读取
* 3. 如果权限被拒绝,会在实际操作时处理
*/
async init() {
Log.Info('[Clipboard] Initializing...');
// 检查 Clipboard API 是否可用
if (!navigator.clipboard) {
Log.Warn('[Clipboard] Clipboard API not supported');
this._updateStatus('error', _('Clipboard API not supported'));
return;
}
// 尝试查询权限状态(某些浏览器可能不支持)
let permissionState = 'unknown';
if (navigator.permissions && navigator.permissions.query) {
try {
// 注意:某些浏览器不支持 "clipboard-read" 权限查询
const result = await navigator.permissions.query({ name: "clipboard-read" });
permissionState = result.state;
Log.Info('[Clipboard] Permission query result: ' + permissionState);
} catch (err) {
// 权限查询不支持(如 Firefox),这是正常的
Log.Debug('[Clipboard] Permission query not supported: ' + err.message);
permissionState = 'unknown';
}
}
// 根据权限状态决定行为
if (permissionState === 'denied') {
// 权限明确被拒绝
Log.Info('[Clipboard] Permission denied, disabling auto sync');
this.autoSync = false;
this._updateStatus('inactive', '');
} else {
// granted / prompt / unknown 都默认开启
// 实际权限检查会在用户手势时进行
Log.Info('[Clipboard] Enabling auto sync (permission: ' + permissionState + ')');
this.autoSync = true;
this._startSync(false); // 不立即执行,等待用户手势
this._updateStatus('active', _('Auto sync enabled'));
}
}
/**
* Toggle auto-sync state
* @param {boolean} enable
* @returns {Promise} resulting state
*/
async toggleAutoSync(enable) {
if (enable) {
const hasPermission = await this._requestPermission();
if (!hasPermission) {
this._updateStatus('error', _('Clipboard permission denied'));
return false;
}
this.autoSync = true;
this._startSync();
this._updateStatus('active', _('Auto sync enabled'));
Log.Info('[Clipboard] Auto sync enabled');
} else {
this.autoSync = false;
this._stopSync();
this._updateStatus('inactive', '');
Log.Info('[Clipboard] Auto sync disabled');
}
return this.autoSync;
}
/**
* Receive text from remote and sync to local if enabled
* @param {string} text
*/
handleRemoteText(text) {
if (this.autoSync) {
this.writeLocal(text);
}
}
/**
* Read from local clipboard manually
*/
async readLocal() {
try {
if (!navigator.clipboard || !navigator.clipboard.readText) {
this._updateStatus('error', _('Clipboard API not supported'));
return null;
}
const text = await navigator.clipboard.readText();
this.lastLocalText = text;
Log.Debug('[Clipboard] Read from local: ' + text.substr(0, 40) + '...');
this._updateStatus('success', _('Read from local clipboard'));
this._resetStatus();
return text;
} catch (err) {
Log.Warn('[Clipboard] Failed to read local: ' + err.message);
this._updateStatus('error', _('Failed to read clipboard'));
return null;
}
}
/**
* Write to local clipboard
* @param {string} text
*/
async writeLocal(text) {
try {
if (!navigator.clipboard || !navigator.clipboard.writeText) {
this._updateStatus('error', _('Clipboard API not supported'));
return;
}
await navigator.clipboard.writeText(text);
this.lastLocalText = text;
Log.Debug('[Clipboard] Wrote to local: ' + text.substr(0, 40) + '...');
this._updateStatus('success', _('Copied to local clipboard'));
this._resetStatus();
} catch (err) {
Log.Warn('[Clipboard] Failed to write local: ' + err.message);
this._updateStatus('error', _('Failed to write clipboard'));
}
}
/**
* Send text to remote
* @param {string} text
*/
sendToRemote(text) {
if (this.rfb) {
this.rfb.clipboardPasteFrom(text);
Log.Debug('[Clipboard] Sent to remote: ' + text.substr(0, 40) + '...');
}
}
// Private methods
async _requestPermission() {
try {
if (navigator.clipboard && navigator.clipboard.readText) {
await navigator.clipboard.readText();
this.permissionGranted = true;
return true;
}
return false;
} catch (err) {
if (err.name === 'NotAllowedError') {
return false;
}
// Other errors (like empty clipboard) usually mean we have permission
return true;
}
}
_startSync(runImmediately = true) {
this.syncFocusHandler = async () => {
if (!this.autoSync || !this.rfb) return;
try {
// Focus event is a user gesture, so readText should work/prompt
const text = await navigator.clipboard.readText();
if (text && text !== this.lastLocalText) {
this.lastLocalText = text;
this.sendToRemote(text);
}
} catch (err) {
// If it fails (e.g. denied), we might want to turn off autoSync?
// For now, just log and maybe show status
Log.Warn('[Clipboard] Auto-sync read failed: ' + err);
if (err.name === 'NotAllowedError') {
this._updateStatus('error', _('Clipboard permission denied'));
this.autoSync = false; // Disable if denied
// We might want to notify UI to uncheck box
// But we don't have a direct way to sync back to checkbox unless we use callback
// Current arch: init sets checkbox. toggle sets checkbox.
// If we change state here, checkbox might desync.
// We can use onStatusChange to signal "error"? Or add onStateChange?
// For now, keep simple.
}
}
};
window.addEventListener('focus', this.syncFocusHandler);
if (runImmediately) {
this.syncFocusHandler(); // Initial sync
}
}
_stopSync() {
if (this.syncFocusHandler) {
window.removeEventListener('focus', this.syncFocusHandler);
this.syncFocusHandler = null;
}
this.lastLocalText = null;
}
_updateStatus(status, message) {
if (this.onStatusChange) {
this.onStatusChange(status, message);
}
}
_resetStatus() {
setTimeout(() => {
if (this.autoSync) {
this._updateStatus('active', _('Auto sync enabled'));
} else {
this._updateStatus('inactive', '');
}
}, 2000);
}
}
const clipboardManager = new ClipboardManager();
export default clipboardManager;