253 lines
8.3 KiB
JavaScript
253 lines
8.3 KiB
JavaScript
/*
|
||
* 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<boolean>} 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;
|