Merge pull request #19 from Heavrnl/feature/vnc

Feature/vnc
This commit is contained in:
Baobhan Sith
2025-05-08 08:08:04 +08:00
committed by GitHub
41 changed files with 1881 additions and 2151 deletions
+6 -4
View File
@@ -1,6 +1,8 @@
# local/docker
DEPLOYMENT_MODE=docker
DEPLOYMENT_MODE=local
RDP_SERVICE_URL_DOCKER=ws://rdp:8081
RDP_SERVICE_URL_LOCAL=ws://localhost:8081
# Backend API Base URLs
REMOTE_GATEWAY_API_BASE_LOCAL=http://localhost:9090
REMOTE_GATEWAY_API_BASE_DOCKER=http://remote-gateway:9090
REMOTE_GATEWAY_WS_URL_LOCAL=ws://localhost:8080
REMOTE_GATEWAY_WS_URL_DOCKER=ws://remote-gateway:8080
+26
View File
@@ -10,6 +10,7 @@ services:
depends_on:
- backend
- rdp
- vnc
networks:
- nexus-terminal-network
@@ -27,6 +28,7 @@ services:
NODE_ENV: production
PORT: 3001
RDP_BACKEND_API_BASE: http://rdp:9090
VNC_BACKEND_API_BASE: http://vnc:9091
volumes:
- ./data:/app/data
networks:
@@ -52,6 +54,30 @@ services:
- guacd
- backend
vnc:
build:
context: .
dockerfile: packages/vnc/Dockerfile
image: nexus-vnc # 保持与 docker-compose.yml 中的 image 名称一致
container_name: nexus-vnc
ports:
- "9091:9091"
- "8082:8082"
environment:
GUACD_HOSTNAME: guacd
GUACD_PORT: 4822
VNC_PORT: 9091
VNC_WS_PORT: 8082
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
FRONTEND_URL: ${FRONTEND_URL}
MAIN_BACKEND_URL: ${MAIN_BACKEND_URL}
NODE_ENV: production
networks:
- nexus-terminal-network
depends_on:
- guacd
- backend
guacd:
image: guacamole/guacd:latest
container_name: nexus-terminal-guacd
+8 -8
View File
@@ -6,7 +6,7 @@ services:
- "18111:80"
depends_on:
- backend
- rdp
- remote-gateway
networks:
- nexus-terminal-network
@@ -14,24 +14,24 @@ services:
image: heavrnl/nexus-terminal-backend:latest
container_name: nexus-terminal-backend
env_file:
- .env
- .env
environment:
NODE_ENV: production
PORT: 3001
RDP_BACKEND_API_BASE: http://rdp:9090
REMOTE_GATEWAY_API_BASE: http://remote-gateway:9090
volumes:
- ./data:/app/data
networks:
- nexus-terminal-network
rdp:
image: heavrnl/nexus-terminal-rdp:latest
container_name: nexus-terminal-rdp
remote-gateway:
image: heavrnl/nexus-terminal-remote-gateway:latest
container_name: nexus-terminal-remote-gateway
environment:
GUACD_HOST: guacd
GUACD_PORT: 4822
API_PORT: 9090
GUAC_WS_PORT: 8081
REMOTE_GATEWAY_API_PORT: 9090
REMOTE_GATEWAY_WS_PORT: 8080
FRONTEND_URL: http://frontend
MAIN_BACKEND_URL: http://backend:3001
NODE_ENV: production
+1 -1
View File
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
# Copy workspace package.json files to ensure npm ci works correctly in monorepo
COPY packages/backend/package.json ./packages/backend/
COPY packages/frontend/package.json ./packages/frontend/
COPY packages/rdp/package.json ./packages/rdp/
COPY packages/remote-gateway/package.json ./packages/remote-gateway/
# Install dependencies (using install instead of ci for potential armv7/alpine compatibility issues)
RUN npm install
@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import * as ConnectionService from '../services/connection.service';
import * as SshService from '../services/ssh.service';
import * as GuacamoleService from '../services/guacamole.service'; // 导入 GuacamoleService
import * as ImportExportService from '../services/import-export.service';
import * as ConnectionRepository from '../repositories/connection.repository'; // +++ 导入 ConnectionRepository +++
@@ -269,10 +270,9 @@ export const importConnections = async (req: Request, res: Response): Promise<vo
}
}
};
import axios from 'axios'; // +++ Import axios +++
import axios from 'axios'; // axios 仍可能用于错误检查类型
// TODO: Make RDP backend URL configurable
const RDP_BACKEND_API_BASE = process.env.RDP_BACKEND_API_BASE || 'http://localhost:9090';
// RDP_BACKEND_API_BASE and VNC_BACKEND_API_BASE are now handled in GuacamoleService
/**
* 获取 RDP 会话的 Guacamole 令牌 (通过调用 RDP 后端)
@@ -320,66 +320,141 @@ export const getRdpSessionToken = async (req: Request, res: Response): Promise<v
return;
}
// 4. 准备调用 RDP 后端的参数
const rdpApiParams = new URLSearchParams({
hostname: connection.host,
port: connection.port.toString(),
username: connection.username,
password: decryptedPassword, // 使用解密后的密码
// Add other RDP parameters from connection object if needed by rdp backend
security: (connection as any).rdp_security || 'any',
ignoreCert: String((connection as any).rdp_ignore_cert ?? true),
});
const rdpTokenUrl = `${RDP_BACKEND_API_BASE}/api/get-token?${rdpApiParams.toString()}`;
// 4. 调用 GuacamoleService 获取 RDP 令牌
// 注意:从 connection.extras 或其他地方获取 RDP 特定的 width, height, dpi
const { width, height, dpi } = req.query; // 或者从 connection.extras 获取
const rdpWidth = width ? parseInt(width as string, 10) : undefined;
const rdpHeight = height ? parseInt(height as string, 10) : undefined;
const rdpDpi = dpi ? dpi as string : undefined;
console.log(`[Controller:getRdpSessionToken] Calling RDP backend API: ${RDP_BACKEND_API_BASE}/api/get-token?...`);
const guacamoleToken = await GuacamoleService.getRemoteDesktopToken('rdp', connection, decryptedPassword, rdpWidth, rdpHeight, rdpDpi);
console.log(`[Controller:getRdpSessionToken] Received Guacamole token via GuacamoleService for RDP connection ${connectionId}`);
// 5. 调用 RDP 后端 API 获取 Guacamole 令牌
const rdpResponse = await axios.get<{ token: string }>(rdpTokenUrl, {
timeout: 10000 // 设置 10 秒超时
});
if (rdpResponse.status !== 200 || !rdpResponse.data?.token) {
console.error(`[Controller:getRdpSessionToken] RDP backend API call failed or returned invalid data. Status: ${rdpResponse.status}`, rdpResponse.data);
throw new Error('从 RDP 后端获取令牌失败。');
}
const guacamoleToken = rdpResponse.data.token;
console.log(`[Controller:getRdpSessionToken] Received Guacamole token from RDP backend for connection ${connectionId}`);
// 6. 将 Guacamole 令牌返回给前端
// 5. Guacamole 令牌返回给前端
res.status(200).json({ token: guacamoleToken });
} catch (error: any) {
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error);
console.error(`Controller: 获取 RDP 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message);
let statusCode = 500;
let message = '获取 RDP 会话令牌时发生内部服务器错误。';
let responseMessage = '获取 RDP 会话令牌时发生内部服务器错误。';
if (axios.isAxiosError(error)) {
message = '调用 RDP 后端服务时出错。';
if (error.message.includes('调用 RDP 后端服务失败') || error.message.includes('从 RDP 后端获取令牌失败') || error.message.includes('调用 Remote Gateway API 时出错 (RDP)')) {
responseMessage = error.message;
if (error.message.includes('(状态: 4')) statusCode = 400;
else if (error.message.includes('(状态: 5')) statusCode = 502;
else statusCode = 503;
} else if (error.message.includes('RDP 连接需要使用密码认证') || error.message.includes('密码解密失败') || error.message.includes('RDP 连接使用密码认证,但密码解密失败或未提供密码')) {
responseMessage = error.message;
statusCode = 400;
} else if (error.message.includes('连接类型必须是 RDP')) {
responseMessage = error.message;
statusCode = 400;
}
else if (axios.isAxiosError(error)) {
responseMessage = '调用远程桌面网关服务时发生网络或请求错误。';
if (error.response) {
// RDP 后端返回了错误响应
console.error('[Controller:getRdpSessionToken] RDP backend error response:', error.response.data);
message += ` (状态: ${error.response.status})`;
statusCode = error.response.status >= 500 ? 502 : 400; // Bad Gateway or Bad Request
console.error('[Controller:getRdpSessionToken] Remote Gateway error response:', error.response.data);
responseMessage += ` (状态: ${error.response.status})`;
statusCode = error.response.status >= 500 ? 502 : 400;
} else if (error.request) {
// 请求已发出但没有收到响应 (网络问题、超时)
console.error('[Controller:getRdpSessionToken] No response from RDP backend.');
message += ' (无法连接或超时)';
statusCode = 504; // Gateway Timeout
} else {
// 设置请求时发生错误
console.error('[Controller:getRdpSessionToken] Axios request setup error:', error.message);
console.error('[Controller:getRdpSessionToken] No response from Remote Gateway.');
responseMessage += ' (无法连接或超时)';
statusCode = 504;
}
} else if (error.message.includes('解密失败')) {
message = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
responseMessage = '获取 RDP 会话令牌时发生内部错误(凭证处理失败)。';
}
res.status(statusCode).json({ message });
res.status(statusCode).json({ message: responseMessage });
}
};
/**
* 获取 VNC 会话的 Guacamole 令牌 (通过调用 Guacamole 服务)
* GET /api/v1/connections/:id/vnc-session
*/
export const getVncSessionToken = async (req: Request, res: Response): Promise<void> => {
try {
const connectionId = parseInt(req.params.id, 10);
if (isNaN(connectionId)) {
res.status(400).json({ message: '无效的连接 ID。' });
return;
}
const connectionData = await ConnectionService.getConnectionWithDecryptedCredentials(connectionId);
if (!connectionData) {
res.status(404).json({ message: '连接未找到。' });
return;
}
const { connection, decryptedPassword } = connectionData;
if (connection.type !== 'VNC') {
res.status(400).json({ message: '此连接类型不是 VNC。' });
return;
}
try {
const currentTimeSeconds = Math.floor(Date.now() / 1000);
await ConnectionRepository.updateLastConnected(connectionId, currentTimeSeconds);
console.log(`[Controller:getVncSessionToken] 已更新 VNC 连接 ${connectionId} 的 last_connected_at 为 ${currentTimeSeconds}`);
} catch (updateError) {
console.error(`[Controller:getVncSessionToken] 更新 VNC 连接 ${connectionId} 的 last_connected_at 时出错:`, updateError);
}
if (connection.auth_method !== 'password' || !decryptedPassword) {
console.warn(`[Controller:getVncSessionToken] VNC connection ${connectionId} does not use password auth or password decryption failed.`);
res.status(400).json({ message: 'VNC 连接需要使用密码认证,或密码解密失败。' });
return;
}
const { width, height } = req.query;
const initialWidth = width ? parseInt(width as string, 10) : undefined;
const initialHeight = height ? parseInt(height as string, 10) : undefined;
const guacamoleToken = await GuacamoleService.getRemoteDesktopToken('vnc', connection, decryptedPassword, initialWidth, initialHeight);
console.log(`[Controller:getVncSessionToken] Received Guacamole token via GuacamoleService for VNC connection ${connectionId} with size ${initialWidth}x${initialHeight}`);
res.status(200).json({ token: guacamoleToken });
} catch (error: any) {
console.error(`Controller: 获取 VNC 会话令牌时发生错误 (ID: ${req.params.id}):`, error.message);
let statusCode = 500;
let responseMessage = '获取 VNC 会话令牌时发生内部服务器错误。';
if (error.message.includes('调用 VNC 后端服务失败') || error.message.includes('从 VNC 后端获取令牌失败') || error.message.includes('调用 Remote Gateway API 时出错 (VNC)')) {
responseMessage = error.message;
if (error.message.includes('(状态: 4')) statusCode = 400;
else if (error.message.includes('(状态: 5')) statusCode = 502;
else statusCode = 503;
} else if (error.message.includes('VNC 连接需要使用密码认证') || error.message.includes('密码解密失败') || error.message.includes('VNC 连接使用密码认证,但密码解密失败或未提供密码')) {
responseMessage = error.message;
statusCode = 400;
} else if (error.message.includes('连接类型必须是 VNC')) {
responseMessage = error.message;
statusCode = 400;
}
else if (axios.isAxiosError(error)) {
responseMessage = '调用远程桌面网关服务时发生网络或请求错误。';
if (error.response) {
console.error('[Controller:getVncSessionToken] Remote Gateway error response:', error.response.data);
responseMessage += ` (状态: ${error.response.status})`;
statusCode = error.response.status >= 500 ? 502 : 400;
} else if (error.request) {
console.error('[Controller:getVncSessionToken] No response from Remote Gateway.');
responseMessage += ' (无法连接或超时)';
statusCode = 504;
}
} else if (error.message.includes('解密失败')) {
responseMessage = '获取 VNC 会话令牌时发生内部错误(凭证处理失败)。';
}
res.status(statusCode).json({ message: responseMessage });
}
};
/**
* 克隆连接 (POST /api/v1/connections/:id/clone)
*/
@@ -12,6 +12,7 @@ import {
exportConnections,
importConnections,
getRdpSessionToken, // Import the new controller function
getVncSessionToken, // Import the VNC session token controller function
cloneConnection, // +++ Import the clone controller function +++
// updateConnectionTags, // No longer directly used by primary flow
addTagToConnections // +++ Import the new controller function for adding tag to multiple connections +++
@@ -83,6 +84,9 @@ router.post('/test-unsaved', testUnsavedConnection);
// POST /api/v1/connections/:id/rdp-session - Get RDP session token via backend
router.post('/:id/rdp-session', getRdpSessionToken);
// POST /api/v1/connections/:id/vnc-session - Get VNC session token
router.post('/:id/vnc-session', getVncSessionToken);
// +++ POST /api/v1/connections/:id/clone - 克隆连接 +++
router.post('/:id/clone', cloneConnection);
+106
View File
@@ -40,6 +40,16 @@ const columnExists = async (db: Database, tableName: string, columnName: string)
});
};
// 辅助函数:获取表的创建 SQL
const getTableCreateSQL = async (db: Database, tableName: string): Promise<string | null> => {
return new Promise((resolve, reject) => {
db.get("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", [tableName], (err, row: any) => {
if (err) reject(err);
else resolve(row ? row.sql : null);
});
});
};
const definedMigrations: Migration[] = [
{
@@ -121,6 +131,102 @@ const definedMigrations: Migration[] = [
ALTER TABLE connections ADD COLUMN notes TEXT NULL;
`
},
{
id: 5,
name: 'Update connections table to allow VNC type in CHECK constraint',
check: async (db: Database): Promise<boolean> => {
const createSQL = await getTableCreateSQL(db, 'connections');
if (createSQL) {
// 检查 CHECK 约束是否已经包含了 VNC
// 这会检查 'VNC' 是否是允许的类型之一
// 例如: CHECK(type IN ('SSH', 'RDP', 'VNC'))
const constraintRegex = /CHECK\s*\(\s*LOWER\(type\)\s+IN\s*\(([^)]+)\)\s*\)/i; // 兼容大小写不敏感的检查
const constraintRegexStrict = /CHECK\s*\(\s*type\s+IN\s*\(([^)]+)\)\s*\)/i;
let match = createSQL.match(constraintRegex);
if (!match) {
match = createSQL.match(constraintRegexStrict);
}
if (match && match[1]) {
const allowedTypes = match[1].split(',').map(t => t.trim().replace(/'/g, "").toLowerCase());
return !allowedTypes.includes('vnc'); // 如果 'vnc' 不在允许类型中,则需要运行迁移
}
// 如果没有找到明确的 CHECK 约束或格式不匹配,保守地运行迁移
console.warn('[Migrations] Check for VNC in connections.type: Could not parse CHECK constraint from SQL. Assuming migration is needed.');
return true;
}
console.warn('[Migrations] Check for VNC in connections.type: Could not get table create SQL. Assuming migration is needed.');
return true; // 如果表不存在或无法获取 SQL,则运行迁移
},
sql: `
PRAGMA foreign_keys=off;
-- 步骤 1: 重命名旧表
ALTER TABLE connections RENAME TO connections_old_for_vnc_constraint_update;
ALTER TABLE connection_tags RENAME TO connection_tags_old_for_vnc_constraint_update;
-- 步骤 2: 创建新表 (与 schema.ts 中的定义一致)
CREATE TABLE connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NULL,
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')) DEFAULT 'SSH',
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')),
encrypted_password TEXT NULL,
encrypted_private_key TEXT NULL,
encrypted_passphrase TEXT NULL,
proxy_id INTEGER NULL,
ssh_key_id INTEGER NULL,
notes TEXT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
last_connected_at INTEGER NULL,
FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL,
FOREIGN KEY (ssh_key_id) REFERENCES ssh_keys(id) ON DELETE SET NULL
);
CREATE TABLE connection_tags (
connection_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (connection_id, tag_id),
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
-- 步骤 3: 从旧表复制数据到新表
INSERT INTO connections (
id, name, type, host, port, username, auth_method,
encrypted_password, encrypted_private_key, encrypted_passphrase,
proxy_id, ssh_key_id, notes, created_at, updated_at, last_connected_at
)
SELECT
id, name,
CASE
WHEN UPPER(type) = 'RDP' THEN 'RDP'
WHEN UPPER(type) = 'SSH' THEN 'SSH'
WHEN UPPER(type) = 'VNC' THEN 'VNC'
ELSE 'SSH'
END,
host, port, username, auth_method,
encrypted_password, encrypted_private_key, encrypted_passphrase,
proxy_id, ssh_key_id, notes, created_at, updated_at, last_connected_at
FROM connections_old_for_vnc_constraint_update;
INSERT INTO connection_tags (connection_id, tag_id)
SELECT connection_id, tag_id FROM connection_tags_old_for_vnc_constraint_update;
-- 步骤 4: 删除旧表
DROP TABLE connections_old_for_vnc_constraint_update;
DROP TABLE connection_tags_old_for_vnc_constraint_update;
PRAGMA foreign_keys=on;
ANALYZE; -- 重新分析数据库模式
`
},
// --- 未来可以添加更多迁移 ---
];
+1 -1
View File
@@ -76,7 +76,7 @@ export const createConnectionsTableSQL = `
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NULL, -- 允许 name 为空
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP')) DEFAULT 'SSH',
type TEXT NOT NULL CHECK(type IN ('SSH', 'RDP', 'VNC')) DEFAULT 'SSH',
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
+39 -31
View File
@@ -1,10 +1,40 @@
import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs'; // fs is needed for early env loading if data/.env is checked
// --- 开始环境变量的早期加载 ---
// 1. 加载根目录的 .env 文件 (定义部署模式等)
// 注意: __dirname 在 dist/src 中,所以需要回退三级到项目根目录
const projectRootEnvPath = path.resolve(__dirname, '../../../.env');
const rootConfigResult = dotenv.config({ path: projectRootEnvPath });
if (rootConfigResult.error && (rootConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`[ENV Init Early] Warning: Could not load root .env file from ${projectRootEnvPath}. Error: ${rootConfigResult.error.message}`);
} else if (!rootConfigResult.error) {
console.log(`[ENV Init Early] Loaded environment variables from root .env file: ${projectRootEnvPath}`);
} else {
console.log(`[ENV Init Early] Root .env file not found at ${projectRootEnvPath}, proceeding without it.`);
}
// 2. 加载 data/.env 文件 (定义密钥等)
// 注意: 这个路径是相对于编译后的 dist/src/index.js
const dataEnvPathGlobal = path.resolve(__dirname, '../data/.env'); // Renamed to avoid conflict if 'dataEnvPath' is used later
const dataConfigResultGlobal = dotenv.config({ path: dataEnvPathGlobal }); // Renamed
if (dataConfigResultGlobal.error && (dataConfigResultGlobal.error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn(`[ENV Init Early] Warning: Could not load data .env file from ${dataEnvPathGlobal}. Error: ${dataConfigResultGlobal.error.message}`);
} else if (!dataConfigResultGlobal.error) {
console.log(`[ENV Init Early] Loaded environment variables from data .env file: ${dataEnvPathGlobal}`);
}
// --- 结束环境变量的早期加载 ---
import express = require('express');
import { Request, Response, NextFunction, RequestHandler } from 'express';
import http from 'http';
import fs from 'fs';
import path from 'path';
// import fs from 'fs'; // Moved up
// import path from 'path'; // Moved up
import crypto from 'crypto';
import dotenv from 'dotenv';
// import dotenv from 'dotenv'; // Moved up
import session from 'express-session';
import sessionFileStore from 'session-file-store';
import { getDbInstance } from './database/connection';
@@ -54,38 +84,16 @@ process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
const initializeEnvironment = async () => {
// 1. 加载根目录的 .env 文件 (定义部署模式等)
// 注意: __dirname 在 dist/src 中,所以需要回退三级到项目根目录
const projectRootEnvPath = path.resolve(__dirname, '../../../.env');
const rootConfigResult = dotenv.config({ path: projectRootEnvPath });
// Use type assertion for error code checking
if (rootConfigResult.error && (rootConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') {
// 只在文件存在但无法加载时发出警告
console.warn(`[ENV Init] Warning: Could not load root .env file from ${projectRootEnvPath}. Error: ${rootConfigResult.error.message}`);
} else if (!rootConfigResult.error) {
console.log(`[ENV Init] Loaded environment variables from root .env file: ${projectRootEnvPath}`);
} else {
console.log(`[ENV Init] Root .env file not found at ${projectRootEnvPath}, proceeding without it (expected in non-local deployments where env vars are injected).`);
}
// Env files (root and data/.env) are now loaded at the very top of the file.
// This function will now focus on generating keys if they are missing
// and setting defaults for GUACD variables.
// 2. 加载 data/.env 文件 (定义密钥等)
// 注意: 这个路径是相对于编译后的 dist/src/index.js
const dataEnvPath = path.resolve(__dirname, '../data/.env');
// Use the globally defined path for data .env
const dataEnvPath = dataEnvPathGlobal; // Use the path defined at the top
let keysGenerated = false;
let keysToAppend = '';
// dotenv.config 默认不会覆盖已存在的 process.env 变量
// 这意味着如果根 .env 和 data/.env 定义了相同的变量,先加载的(根 .env)的值会优先
const dataConfigResult = dotenv.config({ path: dataEnvPath });
// Use type assertion for error code checking
if (dataConfigResult.error && (dataConfigResult.error as NodeJS.ErrnoException).code !== 'ENOENT') {
// 只在文件存在但无法加载时发出警告,文件不存在是正常情况
console.warn(`[ENV Init] Warning: Could not load data .env file from ${dataEnvPath}. Error: ${dataConfigResult.error.message}`);
} else if (!dataConfigResult.error) {
console.log(`[ENV Init] Loaded environment variables from data .env file: ${dataEnvPath}`);
}
// 2. 检查 ENCRYPTION_KEY
// 检查 ENCRYPTION_KEY (process.env should be populated by early loading)
if (!process.env.ENCRYPTION_KEY) {
console.log('[ENV Init] ENCRYPTION_KEY 未设置,正在生成...');
const newEncryptionKey = crypto.randomBytes(32).toString('hex');
@@ -7,7 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
interface ConnectionBase {
id: number;
name: string | null;
type: 'SSH' | 'RDP'; // Add type field
type: 'SSH' | 'RDP' | 'VNC'; // Add type field
host: string;
port: number;
username: string;
@@ -43,9 +43,9 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
console.log('[Service:createConnection] Received input:', JSON.stringify(input, null, 2)); // Log input
// 1. 验证输入 (包含 type)
// Convert type to uppercase for validation and consistency
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined; // Ensure type safety
if (!connectionType || !['SSH', 'RDP'].includes(connectionType)) {
throw new Error('必须提供有效的连接类型 (SSH RDP)。');
const connectionType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined; // Ensure type safety
if (!connectionType || !['SSH', 'RDP', 'VNC'].includes(connectionType)) {
throw new Error('必须提供有效的连接类型 (SSH, RDP 或 VNC)。');
}
if (!input.host || !input.username) {
throw new Error('缺少必要的连接信息 (host, username)。');
@@ -70,6 +70,18 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
throw new Error('RDP 连接需要提供 password。');
}
// For RDP, we'll ignore auth_method, private_key, passphrase from input if provided
} else if (connectionType === 'VNC') {
if (!input.password) {
throw new Error('VNC 连接需要提供 password。');
}
// For VNC, auth_method is implicitly 'password'.
// ssh_key_id, private_key, passphrase are not applicable.
if (input.auth_method && input.auth_method !== 'password') {
throw new Error('VNC 连接的认证方式必须是 password。');
}
if (input.ssh_key_id || input.private_key) {
throw new Error('VNC 连接不支持 SSH 密钥认证。');
}
}
// 2. 处理凭证和 ssh_key_id (根据 type)
@@ -108,16 +120,27 @@ export const createConnection = async (input: CreateConnectionInput): Promise<Co
throw new Error('SSH 密钥认证方式内部错误:未提供 private_key 或 ssh_key_id。');
}
}
} else { // RDP (connectionType is 'RDP')
} else if (connectionType === 'RDP') { // RDP
encryptedPassword = encrypt(input.password!);
// authMethodForDb remains 'password' for RDP to satisfy DB constraint
// Ensure SSH specific fields are null for RDP
// authMethodForDb remains 'password' for RDP
encryptedPrivateKey = null;
encryptedPassphrase = null;
sshKeyIdToSave = null;
} else { // VNC
encryptedPassword = encrypt(input.password!);
authMethodForDb = 'password'; // VNC always uses password auth
encryptedPrivateKey = null;
encryptedPassphrase = null;
sshKeyIdToSave = null;
}
// 3. 准备仓库数据
const defaultPort = input.type === 'RDP' ? 3389 : 22;
let defaultPort = 22; // Default for SSH
if (connectionType === 'RDP') {
defaultPort = 3389;
} else if (connectionType === 'VNC') {
defaultPort = 5900; // Default VNC port
}
// +++ Explicitly type connectionData using the local alias +++
const connectionData: ConnectionDataForRepo = {
name: input.name || '',
@@ -178,7 +201,7 @@ export const updateConnection = async (id: number, input: UpdateConnectionInput)
const dataToUpdate: Partial<Omit<ConnectionRepository.FullConnectionData & { ssh_key_id?: number | null }, 'id' | 'created_at' | 'last_connected_at' | 'tag_ids'>> = {};
let needsCredentialUpdate = false;
// Determine the final type, converting input type to uppercase if provided
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | undefined || currentFullConnection.type;
const targetType = input.type?.toUpperCase() as 'SSH' | 'RDP' | 'VNC' | undefined || currentFullConnection.type;
// 更新非凭证字段
if (input.name !== undefined) dataToUpdate.name = input.name || '';
@@ -280,20 +303,32 @@ if (input.notes !== undefined) dataToUpdate.notes = input.notes; // Add notes up
dataToUpdate.encrypted_password = null;
}
}
} else { // targetType is 'RDP'
} else if (targetType === 'RDP') { // targetType is 'RDP'
// RDP only uses password
if (input.password !== undefined) { // Check if password was provided
// Encrypt if password is not empty, otherwise set to null (to clear)
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
needsCredentialUpdate = true;
}
// Ensure SSH specific fields are nullified if switching to RDP or updating RDP
if (targetType !== currentFullConnection.type || needsCredentialUpdate) {
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
dataToUpdate.auth_method = 'password'; // RDP uses password auth method in DB
dataToUpdate.encrypted_private_key = null;
dataToUpdate.encrypted_passphrase = null;
dataToUpdate.ssh_key_id = null; // RDP cannot use ssh_key_id
}
} else { // targetType is 'VNC'
// VNC only uses password
if (input.password !== undefined) { // Check if password was provided
dataToUpdate.encrypted_password = input.password ? encrypt(input.password) : null;
needsCredentialUpdate = true;
}
// Ensure SSH specific fields are nullified if switching to VNC or updating VNC
if (targetType !== currentFullConnection.type || needsCredentialUpdate || Object.keys(dataToUpdate).includes('type')) {
dataToUpdate.auth_method = 'password'; // VNC uses password auth method in DB
dataToUpdate.encrypted_private_key = null;
dataToUpdate.encrypted_passphrase = null;
dataToUpdate.ssh_key_id = null; // VNC cannot use ssh_key_id
}
}
// 3. 如果有更改,则更新连接记录
@@ -1 +1,93 @@
// This file is intentionally left blank as Guacamole logic is handled by the separate rdp package.
import axios from 'axios';
import { ConnectionWithTags } from '../types/connection.types';
// 统一远程桌面网关服务的 Base URL
const REMOTE_GATEWAY_API_BASE = process.env.DEPLOYMENT_MODE === 'local'
? process.env.REMOTE_GATEWAY_API_BASE_LOCAL || 'http://localhost:9090'
: process.env.REMOTE_GATEWAY_API_BASE_DOCKER || 'http://remote-gateway:9090';
console.log(`[GuacamoleService] DEPLOYMENT_MODE: ${process.env.DEPLOYMENT_MODE}`);
console.log(`[GuacamoleService] Using Remote Gateway API Base (Local): ${process.env.REMOTE_GATEWAY_API_BASE_LOCAL}`);
console.log(`[GuacamoleService] Using Remote Gateway API Base (Docker): ${process.env.REMOTE_GATEWAY_API_BASE_DOCKER}`);
console.log(`[GuacamoleService] Effective Remote Gateway API Base: ${REMOTE_GATEWAY_API_BASE}`);
interface TokenResponse {
token: string;
}
/**
* 从统一远程桌面网关服务获取 Guacamole 令牌
* @param protocol 'rdp' 或 'vnc'
* @param connection 连接对象
* @param decryptedPassword 解密后的密码
* @param width 宽度
* @param height 高度
* @param dpi DPI (主要用于 RDP)
* @returns Guacamole 令牌
*/
export const getRemoteDesktopToken = async (
protocol: 'rdp' | 'vnc',
connection: ConnectionWithTags,
decryptedPassword?: string,
width?: number,
height?: number,
dpi?: string // DPI 主要用于 RDP
): Promise<string> => {
if ((protocol === 'rdp' || protocol === 'vnc') && connection.auth_method === 'password' && !decryptedPassword) {
console.warn(`[GuacamoleService:getRemoteDesktopToken] ${protocol.toUpperCase()} connection ${connection.id} uses password auth but password decryption failed or password not provided.`);
throw new Error(`${protocol.toUpperCase()} 连接使用密码认证,但密码解密失败或未提供密码。`);
}
const connectionConfig: any = {
hostname: connection.host,
port: connection.port.toString(),
width: String(width || 1024), // 提供默认值
height: String(height || 768), // 提供默认值
};
if (protocol === 'rdp') {
if (!connection.username) {
console.warn(`[GuacamoleService:getRemoteDesktopToken] RDP connection ${connection.id} is missing username.`);
// 对于RDP,用户名通常是必需的,但让网关决定是否可以为空
}
connectionConfig.username = connection.username || ''; // RDP 通常需要用户名
connectionConfig.password = decryptedPassword || ''; // RDP 通常需要密码
connectionConfig.dpi = dpi || '96';
connectionConfig.security = (connection as any).rdp_security || 'any';
connectionConfig.ignoreCert = String((connection as any).rdp_ignore_cert ?? true);
} else if (protocol === 'vnc') {
connectionConfig.password = decryptedPassword || ''; // VNC 通常需要密码
if (connection.username) { // VNC 用户名是可选的
connectionConfig.username = connection.username;
}
// 其他 VNC 特定参数可以从 connection.extras 获取
// 例如: if (connection.extras?.enableAudio) connectionConfig.enableAudio = connection.extras.enableAudio;
}
const requestBody = {
protocol,
connectionConfig
};
const tokenUrl = `${REMOTE_GATEWAY_API_BASE}/api/remote-desktop/token`;
console.log(`[GuacamoleService:getRemoteDesktopToken] Calling Remote Gateway API: ${tokenUrl} for protocol ${protocol}, connection ${connection.id}`);
try {
const response = await axios.post<TokenResponse>(tokenUrl, requestBody, {
timeout: 10000 // 10 秒超时
});
if (response.status !== 200 || !response.data?.token) {
console.error(`[GuacamoleService:getRemoteDesktopToken] ${protocol.toUpperCase()} backend API call failed or returned invalid data. Status: ${response.status}`, response.data);
throw new Error(`${protocol.toUpperCase()} 后端获取令牌失败。`);
}
console.log(`[GuacamoleService:getRemoteDesktopToken] Received Guacamole token from ${protocol.toUpperCase()} backend for connection ${connection.id}`);
return response.data.token;
} catch (error: any) {
console.error(`[GuacamoleService:getRemoteDesktopToken] Error calling ${protocol.toUpperCase()} backend for connection ${connection.id}:`, error.message);
if (axios.isAxiosError(error) && error.response) {
throw new Error(`调用 ${protocol.toUpperCase()} 后端服务失败 (状态: ${error.response.status}): ${error.response.data?.message || error.message}`);
}
throw new Error(`调用 ${protocol.toUpperCase()} 后端服务时发生错误: ${error.message}`);
}
};
@@ -7,7 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
interface ImportedConnectionData {
name: string;
type: 'SSH' | 'RDP'; // Add type field
type: 'SSH' | 'RDP' | 'VNC'; // Add type field
host: string;
port: number;
username: string;
@@ -158,7 +158,7 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
try {
// Validate imported data, including type
if (!connData.type || !['SSH', 'RDP'].includes(connData.type)) {
if (!connData.type || !['SSH', 'RDP', 'VNC'].includes(connData.type)) {
throw new Error('缺少或无效的连接类型 (type)。');
}
if (!connData.name || !connData.host || !connData.port || !connData.username) {
@@ -205,7 +205,7 @@ export const importConnections = async (fileBuffer: Buffer): Promise<ImportResul
}
// Prepare data for repository, ensuring correct auth_method for RDP
const authMethodForDb = connData.type === 'RDP' ? 'password' : connData.auth_method!;
const authMethodForDb = (connData.type === 'RDP' || connData.type === 'VNC') ? 'password' : connData.auth_method!;
connectionsToInsert.push({
name: connData.name,
type: connData.type, // Add type
@@ -47,6 +47,8 @@ export const settingsController = {
'timezone', // NEW: 添加时区键
'rdpModalWidth', // NEW: 添加 RDP 模态框宽度键
'rdpModalHeight', // NEW: 添加 RDP 模态框高度键
'vncModalWidth', // NEW: 添加 VNC 模态框宽度键
'vncModalHeight', // NEW: 添加 VNC 模态框高度键
'ipBlacklistEnabled', // <-- 添加 IP 黑名单启用键
'layoutLocked', // +++ 添加布局锁定键 +++
'terminalScrollbackLimit', // NEW: 添加终端回滚行数键
@@ -3,7 +3,7 @@
export interface ConnectionBase {
id: number;
name: string | null;
type: 'SSH' | 'RDP';
type: 'SSH' | 'RDP' | 'VNC';
host: string;
port: number;
username: string;
@@ -22,7 +22,7 @@ export interface ConnectionWithTags extends ConnectionBase {
export interface CreateConnectionInput {
name?: string;
type: 'SSH' | 'RDP';
type: 'SSH' | 'RDP' | 'VNC';
host: string;
port?: number;
username: string;
@@ -39,7 +39,7 @@ notes?: string | null; // 新增备注字段
export interface UpdateConnectionInput {
name?: string;
type?: 'SSH' | 'RDP';
type?: 'SSH' | 'RDP' | 'VNC';
host?: string;
port?: number;
username?: string;
@@ -57,7 +57,7 @@ notes?: string | null; // 新增备注字段
export interface FullConnectionData {
id: number;
name: string | null;
type: 'SSH' | 'RDP';
type: 'SSH' | 'RDP' | 'VNC';
host: string;
port: number;
username: string;
+18 -17
View File
@@ -530,26 +530,27 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
// Determine RDP target URL based on deployment mode
const deploymentMode = process.env.DEPLOYMENT_MODE; // Default to docker mode
let rdpBaseUrl: string;
const deploymentMode = process.env.DEPLOYMENT_MODE;
let remoteGatewayWsBaseUrl: string;
if (deploymentMode === 'local') {
rdpBaseUrl = process.env.RDP_SERVICE_URL_LOCAL || 'ws://localhost:8081'; // Default for local, fallback to localhost:3001
console.log(`[WebSocket RDP Proxy] Using LOCAL deployment mode. RDP Target Base: ${rdpBaseUrl}`);
} else if (deploymentMode === 'docker') { // Explicitly check for docker mode
rdpBaseUrl = process.env.RDP_SERVICE_URL_DOCKER || 'ws://rdp:8081'; // Default for docker, fallback to localhost:3001
console.log(`[WebSocket RDP Proxy] Using DOCKER deployment mode. RDP Target Base: ${rdpBaseUrl}`);
} else { // Handle unknown modes
rdpBaseUrl = 'ws://localhost:8081'; // Fallback to a safe default for unknown modes
console.warn(`[WebSocket RDP Proxy] Unknown deployment mode '${deploymentMode}'. Defaulting to safe fallback RDP Target Base: ${rdpBaseUrl}`);
remoteGatewayWsBaseUrl = process.env.REMOTE_GATEWAY_WS_URL_LOCAL || 'ws://localhost:8080'; // 更新端口和环境变量名
console.log(`[WebSocket Remote Desktop Proxy] Using LOCAL deployment mode. Target Base: ${remoteGatewayWsBaseUrl}`);
} else if (deploymentMode === 'docker') {
remoteGatewayWsBaseUrl = process.env.REMOTE_GATEWAY_WS_URL_DOCKER || 'ws://remote-gateway:8080'; // 更新服务名、端口和环境变量名
console.log(`[WebSocket Remote Desktop Proxy] Using DOCKER deployment mode. Target Base: ${remoteGatewayWsBaseUrl}`);
} else {
remoteGatewayWsBaseUrl = 'ws://localhost:8080'; // 更新默认端口
console.warn(`[WebSocket Remote Desktop Proxy] Unknown deployment mode '${deploymentMode}'. Defaulting to safe fallback Target Base: ${remoteGatewayWsBaseUrl}`);
}
const cleanRdpBaseUrl = rdpBaseUrl.endsWith('/') ? rdpBaseUrl.slice(0, -1) : rdpBaseUrl;
const cleanRemoteGatewayWsBaseUrl = remoteGatewayWsBaseUrl.endsWith('/') ? remoteGatewayWsBaseUrl.slice(0, -1) : remoteGatewayWsBaseUrl;
const rdpTargetUrl = `${cleanRdpBaseUrl}/?token=${encodeURIComponent(rdpToken)}&width=${encodeURIComponent(rdpWidth)}&height=${encodeURIComponent(rdpHeight)}&dpi=${encodeURIComponent(calculatedDpi)}`; // 使用 calculatedDpi
// 构建目标 URL 时,协议 (RDP/VNC) 信息现在由 remote-gateway 处理,我们只需要传递令牌和尺寸
const remoteDesktopTargetUrl = `${cleanRemoteGatewayWsBaseUrl}/?token=${encodeURIComponent(rdpToken)}&width=${encodeURIComponent(rdpWidth)}&height=${encodeURIComponent(rdpHeight)}&dpi=${encodeURIComponent(calculatedDpi)}`;
console.log(`WebSocket: RDP Proxy for ${ws.username} attempting to connect to ${rdpTargetUrl}`);
console.log(`WebSocket: Remote Desktop Proxy for ${ws.username} attempting to connect to ${remoteDesktopTargetUrl}`);
const rdpWs = new WebSocket(rdpTargetUrl);
const rdpWs = new WebSocket(remoteDesktopTargetUrl);
let clientWsClosed = false;
let rdpWsClosed = false;
@@ -586,7 +587,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
});
rdpWs.on('error', (error) => {
console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${rdpTargetUrl} 时出错:`, error);
console.error(`[RDP 代理 RDP WS 错误] 用户: ${ws.username}, 会话: ${ws.sessionId}, 连接到 ${remoteDesktopTargetUrl} 时出错:`, error);
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
console.log(`[RDP 代理] 因 RDP WS 错误关闭客户端 WS。会话: ${ws.sessionId}`);
ws.close(1011, `RDP WS Error: ${error.message}`);
@@ -610,7 +611,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
rdpWs.on('close', (code, reason) => {
rdpWsClosed = true;
// --- 添加中文日志 ---
console.log(`[RDP 代理 RDP WS 关闭] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已关闭。代码: ${code}, 原因: ${reason.toString()}`);
console.log(`[RDP 代理 RDP WS 关闭] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${remoteDesktopTargetUrl} 的连接已关闭。代码: ${code}, 原因: ${reason.toString()}`);
// --- 结束日志 ---
if (!clientWsClosed && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
console.log(`[RDP 代理] 因 RDP WS 关闭而关闭客户端 WS。会话: ${ws.sessionId}`);
@@ -620,7 +621,7 @@ export const initializeWebSocket = async (server: http.Server, sessionParser: Re
});
rdpWs.on('open', () => {
console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${rdpTargetUrl} 的连接已建立。开始转发消息。`);
console.log(`[RDP 代理 RDP WS 打开] 用户: ${ws.username}, 会话: ${ws.sessionId}, 到 ${remoteDesktopTargetUrl} 的连接已建立。开始转发消息。`);
});
// --- 标准 (SSH/SFTP/Docker) 连接处理 ---
+1 -1
View File
@@ -9,7 +9,7 @@ COPY package.json package-lock.json ./
# Copy workspace package.json files to ensure npm ci works correctly in monorepo
COPY packages/backend/package.json ./packages/backend/
COPY packages/frontend/package.json ./packages/frontend/
COPY packages/rdp/package.json ./packages/rdp/
COPY packages/remote-gateway/package.json ./packages/remote-gateway/
# Install dependencies (using install instead of ci for potential armv7 compatibility issues)
RUN npm install
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@nexus-terminal/frontend",
"version": "0.3.5",
"version": "0.4",
"private": true,
"type": "module",
"scripts": {
+10 -1
View File
@@ -20,6 +20,8 @@ import StyleCustomizer from './components/StyleCustomizer.vue';
import FocusSwitcherConfigurator from './components/FocusSwitcherConfigurator.vue';
// +++ 导入 RDP 模态框组件 +++
import RemoteDesktopModal from './components/RemoteDesktopModal.vue';
// +++ 导入 VNC 模态框组件 +++
import VncModal from './components/VncModal.vue';
const { t } = useI18n();
const authStore = useAuthStore();
@@ -33,7 +35,7 @@ const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore);
const { isLayoutVisible, isHeaderVisible } = storeToRefs(layoutStore); // 添加 isHeaderVisible
const { isConfiguratorVisible: isFocusSwitcherVisible } = storeToRefs(focusSwitcherStore);
const { isRdpModalOpen, rdpConnectionInfo } = storeToRefs(sessionStore); // +++ 获取 RDP 状态 +++
const { isRdpModalOpen, rdpConnectionInfo, isVncModalOpen, vncConnectionInfo } = storeToRefs(sessionStore); // +++ 获取 RDP 和 VNC 状态 +++
const breakpoints = useBreakpoints(breakpointsTailwind); // +++ Initialize Breakpoints +++
const isMobile = breakpoints.smaller('md'); // +++ Define isMobile +++
@@ -319,6 +321,13 @@ const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
@close="sessionStore.closeRdpModal()"
/>
<!-- +++ 条件渲染 VNC 模态框 +++ -->
<VncModal
v-if="isVncModalOpen"
:connection="vncConnectionInfo"
@close="sessionStore.closeVncModal()"
/>
</div>
</template>
@@ -30,7 +30,7 @@ const { isLoading: isSshKeyLoading, error: sshKeyStoreError } = storeToRefs(sshK
//
const initialFormData = {
type: 'SSH' as 'SSH' | 'RDP', // Use uppercase to match ConnectionInfo
type: 'SSH' as 'SSH' | 'RDP' | 'VNC', // Use uppercase to match ConnectionInfo
name: '',
host: '',
port: 22,
@@ -42,7 +42,8 @@ const initialFormData = {
selected_ssh_key_id: null as number | null, // +++ Add field for selected key ID +++
proxy_id: null as number | null,
tag_ids: [] as number[], // tag_ids
notes: '', //
notes: '', //
vncPassword: '', // VNC specific password
// Add RDP specific fields later if needed, e.g., domain
};
const formData = reactive({ ...initialFormData });
@@ -79,7 +80,7 @@ watch(() => props.connectionToEdit, (newVal) => {
formError.value = null; //
if (newVal) {
//
formData.type = newVal.type; // Correctly set the type for editing
formData.type = newVal.type as 'SSH' | 'RDP' | 'VNC'; // Correctly set the type for editing
formData.name = newVal.name;
formData.host = newVal.host;
formData.port = newVal.port;
@@ -90,23 +91,34 @@ formData.notes = newVal.notes ?? ''; // 填充备注
formData.tag_ids = newVal.tag_ids ? [...newVal.tag_ids] : []; // tag_ids ()
// +++ selected_ssh_key_id ( key) +++
if (newVal.auth_method === 'key') {
if (newVal.type === 'SSH' && newVal.auth_method === 'key') {
formData.selected_ssh_key_id = newVal.ssh_key_id ?? null;
} else {
formData.selected_ssh_key_id = null; // key
formData.selected_ssh_key_id = null; //
}
//
formData.password = ''; // For SSH/RDP
formData.private_key = '';
formData.passphrase = '';
// formData.vncPassword is already handled by initialFormData or cleared if not VNC
if (newVal.type !== 'VNC') {
formData.vncPassword = '';
} else {
// If editing a VNC connection, we don't get vncPassword from backend directly.
// User needs to re-enter if they want to change it.
// For display, it's kept empty.
formData.vncPassword = '';
}
// ()
formData.password = '';
formData.private_key = ''; // 使 key
formData.passphrase = ''; //
} else {
//
Object.assign(formData, initialFormData);
formData.tag_ids = []; // tag_ids
formData.selected_ssh_key_id = null; //
formData.notes = ''; //
formData.notes = ''; //
formData.vncPassword = ''; // VNC
}
}, { immediate: true });
@@ -120,15 +132,17 @@ onMounted(() => {
//
watch(() => formData.type, (newType) => {
// Use uppercase for comparison
if (newType === 'RDP' && formData.port === 22) {
formData.port = 3389; // RDP
} else if (newType === 'SSH' && formData.port === 3389) {
formData.port = 22; // SSH
}
//
if (newType === 'RDP') {
// RDP auth_method
// formData.auth_method = 'password'; // Example: Force password for RDP
if (formData.port === 22 || formData.port === 5900 || formData.port === 5901) formData.port = 3389; // RDP
formData.auth_method = 'password'; // RDP uses password
formData.selected_ssh_key_id = null; // Clear SSH key selection
} else if (newType === 'SSH') {
if (formData.port === 3389 || formData.port === 5900 || formData.port === 5901) formData.port = 22; // SSH
// auth_method will be handled by its own select
} else if (newType === 'VNC') {
if (formData.port === 22 || formData.port === 3389) formData.port = 5900; // VNC (e.g., 5900 or 5901)
formData.auth_method = 'password'; // VNC uses password, hide auth_method selector
formData.selected_ssh_key_id = null; // Clear SSH key selection
}
});
@@ -190,11 +204,18 @@ const handleSubmit = async () => {
// RDP Validation
// 1.
if (!isEditMode.value && !formData.password) {
formError.value = t('connections.form.errorPasswordRequired'); //
formError.value = t('connections.form.errorPasswordRequired');
return;
}
// 2. RDP
// SSH RDP
// 2.
} else if (formData.type === 'VNC') {
// VNC Validation
// 1. VNC
if (!isEditMode.value && !formData.vncPassword) {
formError.value = t('connections.form.errorVncPasswordRequired', 'VNC 密码是必填项。'); // Add new translation key
return;
}
// 2. VNC
}
// --- ---
@@ -256,6 +277,21 @@ notes: formData.notes, // 添加备注
delete dataToSend.auth_method;
delete dataToSend.private_key;
delete dataToSend.passphrase;
delete dataToSend.vncPassword; // Ensure VNC password field for form doesn't go if RDP
} else if (formData.type === 'VNC') {
// VNC data population
if (formData.vncPassword) {
dataToSend.password = formData.vncPassword; // Backend expects VNC password in 'password' field
} else if (isEditMode.value && formData.vncPassword === '') {
// Editing VNC, empty password means don't change
}
// VNC does not use SSH specific fields
delete dataToSend.auth_method;
delete dataToSend.private_key;
delete dataToSend.passphrase;
delete dataToSend.ssh_key_id;
// formData.vncPassword is used for the form, but backend might expect it as 'password'
// So, we don't send 'vncPassword' itself to backend.
}
@@ -424,6 +460,7 @@ const testButtonText = computed(() => {
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
<option value="SSH">{{ t('connections.form.typeSsh', 'SSH') }}</option>
<option value="RDP">{{ t('connections.form.typeRdp', 'RDP') }}</option>
<option value="VNC">{{ t('connections.form.typeVnc', 'VNC') }}</option>
</select>
</div>
<!-- Host and Port Row -->
@@ -498,13 +535,22 @@ const testButtonText = computed(() => {
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
-->
</div>
</template>
<!-- VNC Specific Auth (Password only) -->
<template v-if="formData.type === 'VNC'">
<div>
<label for="conn-password-vnc" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.vncPassword', 'VNC 密码') }}</label>
<input type="password" id="conn-password-vnc" v-model="formData.vncPassword" :required="!isEditMode" autocomplete="new-password"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" />
</div>
</template>
</div>
</div>
<!-- Advanced Options Section -->
<div class="space-y-4 p-4 border border-border rounded-md bg-header/30">
<h4 class="text-base font-semibold mb-3 pb-2 border-b border-border/50">{{ t('connections.form.sectionAdvanced', '高级选项') }}</h4>
<div v-if="formData.type !== 'RDP'"> <!-- Proxy Select - Hide for RDP -->
<div v-if="formData.type === 'SSH'"> <!-- Proxy Select - Show only for SSH -->
<label for="conn-proxy" class="block text-sm font-medium text-text-secondary mb-1">{{ t('connections.form.proxy') }} ({{ t('connections.form.optional') }})</label>
<select id="conn-proxy" v-model="formData.proxy_id"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
@@ -551,7 +597,6 @@ const testButtonText = computed(() => {
<!-- Form Actions -->
<div class="flex justify-between items-center pt-5 mt-6 flex-shrink-0">
<!-- Test Area (Only show for SSH) -->
<!-- Use uppercase for comparison -->
<div v-if="formData.type === 'SSH'" class="flex flex-col items-start gap-1">
<div class="flex items-center gap-2"> <!-- Button and Icon -->
<button type="button" @click="handleTestConnection" :disabled="isLoading || testStatus === 'testing'"
@@ -583,7 +628,7 @@ const testButtonText = computed(() => {
</div>
</div>
<!-- Placeholder for alignment when test button is hidden -->
<div v-else class="flex-1"></div>
<div v-else class="flex-1"></div> <!-- This div ensures the main action buttons are pushed to the right when test area is hidden -->
<div class="flex space-x-3"> <!-- Main Actions -->
<button type="submit" @click="handleSubmit" :disabled="isLoading || (formData.type === 'SSH' && testStatus === 'testing')"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out">
@@ -182,7 +182,7 @@ const handleDelete = async (conn: ConnectionInfo) => {
<tbody class="divide-y divide-border">
<tr v-for="conn in groupConnections" :key="conn.id" class="hover:bg-hover transition-colors duration-150">
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap flex items-center">
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
<i :class="['fas', conn.type === 'RDP' || conn.type === 'VNC' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
<span>{{ conn.name }}</span>
</td>
<td class="px-4 py-3 text-sm text-foreground whitespace-nowrap">{{ conn.host }}</td>
@@ -287,10 +287,7 @@ const sidebarProps = computed(() => (paneName: PaneName | null, side: 'left' | '
return {
...baseProps,
// Event forwarding
onConnectRequest: (id: number) => {
console.log(`[LayoutRenderer Sidebar] Forwarding 'connect-request' for ID: ${id}`);
emit('connect-request', id);
},
onConnectRequest: (id: number) => emit('connect-request', id),
onOpenNewSession: (id: number) => {
console.log(`[LayoutRenderer Sidebar] Forwarding 'open-new-session' for ID: ${id}`);
emit('open-new-session', id);
@@ -2,6 +2,7 @@
import { ref, onMounted, onUnmounted, watch, nextTick, computed, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSettingsStore } from '../stores/settings.store';
import { useConnectionsStore } from '../stores/connections.store'; // +++ Import connections store +++
// @ts-ignore - guacamole-common-js
import Guacamole from 'guacamole-common-js';
import apiClient from '../utils/apiClient';
@@ -24,81 +25,88 @@ const rdpDisplayRef = ref<HTMLDivElement | null>(null);
const rdpContainerRef = ref<HTMLDivElement | null>(null);
const guacClient = ref<any | null>(null);
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
const isResizing = ref(false);
const resizeStartX = ref(0);
const resizeStartY = ref(0);
const initialModalWidthForResize = ref(0); // Renamed to avoid conflict if other 'initialModalWidth' exists
const initialModalHeightForResize = ref(0); // Renamed
const statusMessage = ref('');
const keyboard = ref<any | null>(null);
const mouse = ref<any | null>(null);
const desiredModalWidth = ref(1064);
const desiredModalHeight = ref(858);
const isKeyboardDisabledForInput = ref(false); //
const isMinimized = ref(false);
const restoreButtonRef = ref<HTMLButtonElement | null>(null);
const isDraggingRestoreButton = ref(false);
const restoreButtonPosition = ref({ x: 16, y: window.innerHeight / 2 - 25 }); // 16px from left, vertically centered
let dragOffsetX = 0;
let dragOffsetY = 0;
let hasDragged = false; // hasDragged
const MIN_MODAL_WIDTH = 1024;
const MIN_MODAL_HEIGHT = 768;
// Dynamically construct WebSocket URL based on environment
let backendBaseUrl: string;
const LOCAL_BACKEND_URL = 'ws://localhost:3001'
const LOCAL_BACKEND_URL = 'ws://localhost:3001'; // For RDP proxy via main backend
// Determine WebSocket URL based on hostname
// Determine WebSocket URL based on hostname for RDP
if (window.location.hostname === 'localhost') {
backendBaseUrl = LOCAL_BACKEND_URL;
console.log(`[RDP 模态框] 使用 localhost WebSocket 基础 URL: ${backendBaseUrl}`);
} else {
// : window.location URL
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHostAndPort = window.location.host;
backendBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws`;
console.log(`[RDP 模态框] 使用生产环境 WebSocket 基础 URL (来自 window.location): ${backendBaseUrl}`);
backendBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws`; // Assuming RDP proxy is at /ws path
}
const connectRdp = async () => {
const handleConnection = async () => {
if (!props.connection || !rdpDisplayRef.value) {
statusMessage.value = t('remoteDesktopModal.errors.missingInfo');
connectionStatus.value = 'error';
return;
}
// Clear previous display and disconnect
while (rdpDisplayRef.value.firstChild) {
rdpDisplayRef.value.removeChild(rdpDisplayRef.value.firstChild);
}
disconnectRdp();
disconnectGuacamole(); // Renamed from disconnectRdp
connectionStatus.value = 'connecting';
statusMessage.value = t('remoteDesktopModal.status.fetchingToken');
try {
const apiUrl = `connections/${props.connection.id}/rdp-session`;
let token: string | null = null;
let tunnelUrl: string = '';
const connectionsStore = useConnectionsStore();
const response = await apiClient.post<{ token: string }>(apiUrl);
if (props.connection.type === 'RDP') {
const apiUrl = `connections/${props.connection.id}/rdp-session`;
const response = await apiClient.post<{ token: string }>(apiUrl);
token = response.data?.token;
if (!token) {
throw new Error('RDP Token not found in API response');
}
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
const token = response.data?.token;
if (!token) {
throw new Error('Token not found in API response');
}
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
await nextTick();
let widthToSend = 800;
let heightToSend = 600;
const dpiToSend = 96;
// DOM RDP
await nextTick();
let widthToSend = 800; // /
let heightToSend = 600; // /
const dpiToSend = 96;
if (rdpContainerRef.value) {
// 使 clientWidth/clientHeight
if (rdpContainerRef.value) {
widthToSend = rdpContainerRef.value.clientWidth;
heightToSend = rdpContainerRef.value.clientHeight - 1; // 1
//
heightToSend = rdpContainerRef.value.clientHeight - 1;
widthToSend = Math.max(100, widthToSend);
heightToSend = Math.max(100, heightToSend);
console.log(`计算出的 RDP 尺寸: ${widthToSend}x${heightToSend}`);
}
tunnelUrl = `${backendBaseUrl}/rdp-proxy?token=${encodeURIComponent(token)}&width=${widthToSend}&height=${heightToSend}&dpi=${dpiToSend}`;
} else {
console.warn("RDP 容器引用不可用,无法获取尺寸。使用默认值。");
//
throw new Error(`Unsupported connection type: ${props.connection.type}`);
}
// 使 URL URL
const tunnelUrl = `${backendBaseUrl}/rdp-proxy?token=${encodeURIComponent(token)}&width=${widthToSend}&height=${heightToSend}&dpi=${dpiToSend}`;
console.log(`[RDP 模态框] 连接到隧道: ${tunnelUrl}`); // URL
// @ts-ignore
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
@@ -107,82 +115,101 @@ const connectRdp = async () => {
const errorCode = status.code || 'N/A';
statusMessage.value = `${t('remoteDesktopModal.errors.tunnelError')} (${errorCode}): ${errorMessage}`;
connectionStatus.value = 'error';
disconnectRdp();
disconnectGuacamole();
};
// @ts-ignore
guacClient.value = new Guacamole.Client(tunnel);
// keep-alive ( 3 NOP)
guacClient.value.keepAliveFrequency = 3000; //
guacClient.value.keepAliveFrequency = 3000;
rdpDisplayRef.value.appendChild(guacClient.value.getDisplay().getElement());
guacClient.value.onstatechange = (state: number) => {
let currentStatus = '';
let i18nKeyPart = 'unknownState';
switch (state) {
case 0:
statusMessage.value = t('remoteDesktopModal.status.idle');
connectionStatus.value = 'disconnected';
case 0: // IDLE
i18nKeyPart = 'idle';
currentStatus = 'disconnected';
break;
case 1:
statusMessage.value = t('remoteDesktopModal.status.connectingRdp');
connectionStatus.value = 'connecting';
case 1: // CONNECTING
i18nKeyPart = 'connectingRdp';
currentStatus = 'connecting';
break;
case 2:
statusMessage.value = t('remoteDesktopModal.status.waiting');
connectionStatus.value = 'connecting';
case 2: // WAITING
i18nKeyPart = 'waiting';
currentStatus = 'connecting';
break;
case 3:
statusMessage.value = t('remoteDesktopModal.status.connected');
connectionStatus.value = 'connected';
case 3: // CONNECTED
i18nKeyPart = 'connected';
currentStatus = 'connected';
setupInputListeners();
// RDP
nextTick(() => {
const displayEl = guacClient.value?.getDisplay()?.getElement();
if (displayEl && typeof displayEl.focus === 'function') {
displayEl.focus();
console.log('[RDP Modal] Focused RDP display after connection.');
} else {
console.warn('[RDP Modal] Could not focus RDP display after connection.');
}
});
setTimeout(() => {
setTimeout(() => { // z-index fix for canvas
nextTick(() => {
if (rdpDisplayRef.value && guacClient.value) {
const canvases = rdpDisplayRef.value.querySelectorAll('canvas');
canvases.forEach((canvas) => {
canvas.style.zIndex = '999';
});
canvases.forEach((canvas) => { canvas.style.zIndex = '999'; });
}
});
}, 100);
break;
case 4:
statusMessage.value = t('remoteDesktopModal.status.disconnecting');
connectionStatus.value = 'disconnected';
case 4: // DISCONNECTING
i18nKeyPart = 'disconnecting';
currentStatus = 'disconnected'; // Or 'disconnecting'
break;
case 5:
statusMessage.value = t('remoteDesktopModal.status.disconnected');
connectionStatus.value = 'disconnected';
case 5: // DISCONNECTED
i18nKeyPart = 'disconnected';
currentStatus = 'disconnected';
break;
default:
statusMessage.value = `${t('remoteDesktopModal.status.unknownState')}: ${state}`;
}
statusMessage.value = t(`remoteDesktopModal.status.${i18nKeyPart}`, { state });
if (currentStatus) connectionStatus.value = currentStatus as 'disconnected' | 'connecting' | 'connected' | 'error';
};
guacClient.value.onerror = (status: any) => {
const errorMessage = status.message || 'Unknown client error';
statusMessage.value = `${t('remoteDesktopModal.errors.clientError')}: ${errorMessage}`;
connectionStatus.value = 'error';
disconnectRdp();
disconnectGuacamole();
};
guacClient.value.connect(''); // ''
guacClient.value.connect('');
} catch (error: any) {
statusMessage.value = `${t('remoteDesktopModal.errors.connectionFailed')}: ${error.response?.data?.message || error.message || String(error)}`;
connectionStatus.value = 'error';
disconnectRdp();
disconnectGuacamole();
}
};
const trySyncClipboardOnDisplayFocus = async () => {
if (!guacClient.value) {
return;
}
try {
const currentClipboardText = await navigator.clipboard.readText();
if (currentClipboardText && guacClient.value) {
// @ts-ignore
const stream = guacClient.value.createClipboardStream('text/plain');
// @ts-ignore
const writer = new Guacamole.StringWriter(stream);
writer.sendText(currentClipboardText);
writer.sendEnd();
console.log('[RemoteDesktopModal] Sent clipboard to RDP on display focus:', currentClipboardText.substring(0, 50) + (currentClipboardText.length > 50 ? '...' : ''));
}
} catch (err) {
if (err instanceof DOMException && err.name === 'NotAllowedError') {
// console.log('[RemoteDesktopModal] Clipboard read on display focus skipped: Document not focused or permission denied.');
} else {
console.warn('[RemoteDesktopModal] Could not read clipboard on display focus, or other error:', err);
}
}
};
@@ -200,6 +227,10 @@ const setupInputListeners = () => {
activeElement.blur();
console.log('[RDP Modal] Blurred input field on RDP display click.');
}
// Ensure the RDP display element gets focus when clicked
if (displayEl && typeof displayEl.focus === 'function') {
displayEl.focus();
}
};
displayEl.addEventListener('click', handleRdpDisplayClick);
@@ -263,6 +294,9 @@ const setupInputListeners = () => {
guacClient.value.sendKeyEvent(0, keysym);
}
};
// Listen for display focus to sync clipboard
displayEl.addEventListener('focus', trySyncClipboardOnDisplayFocus);
} catch (inputError) {
console.error("Error setting up input listeners:", inputError); //
@@ -278,6 +312,7 @@ const removeInputListeners = () => {
if (displayEl) {
//
displayEl.style.cursor = 'default';
displayEl.removeEventListener('focus', trySyncClipboardOnDisplayFocus);
}
} catch (e) {
console.warn("Could not reset cursor or remove listeners on display element during listener removal:", e);
@@ -315,7 +350,56 @@ const enableRdpKeyboard = () => {
});
};
const disconnectRdp = () => {
const minimizeModal = () => {
isMinimized.value = true;
};
const restoreModal = () => {
isMinimized.value = false;
};
const onRestoreButtonMouseDown = (event: MouseEvent) => {
if (!restoreButtonRef.value) return;
hasDragged = false; //
isDraggingRestoreButton.value = true;
dragOffsetX = event.clientX - restoreButtonRef.value.getBoundingClientRect().left;
dragOffsetY = event.clientY - restoreButtonRef.value.getBoundingClientRect().top;
event.preventDefault();
document.addEventListener('mousemove', onRestoreButtonMouseMove);
document.addEventListener('mouseup', onRestoreButtonMouseUp);
};
const onRestoreButtonMouseMove = (event: MouseEvent) => {
if (!isDraggingRestoreButton.value) return;
hasDragged = true; //
let newX = event.clientX - dragOffsetX;
let newY = event.clientY - dragOffsetY;
const buttonWidth = 50;
const buttonHeight = 50;
newX = Math.max(0, Math.min(newX, window.innerWidth - buttonWidth));
newY = Math.max(0, Math.min(newY, window.innerHeight - buttonHeight));
restoreButtonPosition.value = { x: newX, y: newY };
};
const onRestoreButtonMouseUp = () => {
isDraggingRestoreButton.value = false;
document.removeEventListener('mousemove', onRestoreButtonMouseMove);
document.removeEventListener('mouseup', onRestoreButtonMouseUp);
// click mouseup click
// handleClickRestoreButton hasDragged
};
const handleClickRestoreButton = () => {
if (!hasDragged) {
restoreModal();
}
//
hasDragged = false;
};
const disconnectGuacamole = () => {
removeInputListeners();
isKeyboardDisabledForInput.value = false; //
if (guacClient.value) {
@@ -335,7 +419,7 @@ const disconnectRdp = () => {
const closeModal = () => {
disconnectRdp();
disconnectGuacamole();
emit('close');
};
@@ -405,13 +489,12 @@ watchEffect(() => {
desiredModalHeight.value = finalHeight;
});
onMounted(() => {
// watchEffect
if (props.connection) {
nextTick(async () => {
await connectRdp(); // 使
await handleConnection(); // 使
// observer
});
} else {
@@ -421,17 +504,24 @@ onMounted(() => {
});
onUnmounted(() => {
disconnectRdp(); // removeInputListeners
disconnectGuacamole(); // removeInputListeners
document.removeEventListener('mousemove', onRestoreButtonMouseMove);
document.removeEventListener('mouseup', onRestoreButtonMouseUp);
// Clean up resize listeners if component is unmounted while resizing
if (isResizing.value) {
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
}
});
watch(() => props.connection, (newConnection, oldConnection) => {
if (newConnection && newConnection.id !== oldConnection?.id) {
nextTick(async () => {
await connectRdp(); // 使
await handleConnection(); // 使
// observer
});
} else if (!newConnection) {
disconnectRdp();
disconnectGuacamole();
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
connectionStatus.value = 'error';
}
@@ -449,19 +539,90 @@ const computedModalStyle = computed(() => {
};
});
// Watch for modal size changes to update Guacamole client
watchEffect(() => {
const currentStyle = computedModalStyle.value; // Dependency
if (guacClient.value && connectionStatus.value === 'connected' && rdpContainerRef.value) {
nextTick(() => {
if (rdpContainerRef.value && guacClient.value) {
const displayWidth = rdpContainerRef.value.offsetWidth;
const displayHeight = rdpContainerRef.value.offsetHeight;
if (displayWidth > 0 && displayHeight > 0) {
// console.log(`[RDP Modal] Resizing Guacamole display to: ${displayWidth}x${displayHeight} due to style change.`);
guacClient.value.sendSize(displayWidth, displayHeight);
}
}
});
}
});
const initResize = (event: MouseEvent) => {
isResizing.value = true;
resizeStartX.value = event.clientX;
resizeStartY.value = event.clientY;
initialModalWidthForResize.value = desiredModalWidth.value;
initialModalHeightForResize.value = desiredModalHeight.value;
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize);
event.preventDefault();
};
const doResize = (event: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = event.clientX - resizeStartX.value;
const deltaY = event.clientY - resizeStartY.value;
let newWidth = initialModalWidthForResize.value + deltaX;
let newHeight = initialModalHeightForResize.value + deltaY;
newWidth = Math.max(MIN_MODAL_WIDTH, newWidth);
newHeight = Math.max(MIN_MODAL_HEIGHT, newHeight);
desiredModalWidth.value = newWidth;
desiredModalHeight.value = newHeight;
};
const stopResize = () => {
if (!isResizing.value) return;
isResizing.value = false;
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
// Guacamole size update is handled by the watchEffect above
};
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-overlay p-4">
<div
:style="computedModalStyle"
class="bg-background text-foreground rounded-lg shadow-xl flex flex-col overflow-hidden border border-border"
>
<div
:class="[
'fixed inset-0 z-50 flex items-center justify-center p-4',
isMinimized ? '' : 'bg-overlay',
isMinimized ? 'pointer-events-none' : '' //
]"
>
<button
ref="restoreButtonRef"
v-if="isMinimized"
@mousedown="onRestoreButtonMouseDown"
@click="handleClickRestoreButton"
:style="{ left: `${restoreButtonPosition.x}px`, top: `${restoreButtonPosition.y}px`, width: '50px', height: '50px' }"
class="fixed z-[100] flex items-center justify-center bg-primary text-white rounded-full shadow-lg hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50 pointer-events-auto cursor-grab active:cursor-grabbing"
:title="t('common.restore')"
>
<i class="fas fa-window-restore fa-lg"></i>
</button>
<div
v-show="!isMinimized"
:style="computedModalStyle"
class="bg-background text-foreground rounded-lg shadow-xl flex flex-col overflow-hidden border border-border pointer-events-auto relative"
>
<div class="flex items-center justify-between p-3 border-b border-border flex-shrink-0">
<h3 class="text-base font-semibold truncate">
<i class="fas fa-desktop mr-2 text-text-secondary"></i>
{{ t('remoteDesktopModal.title') }} - {{ props.connection?.name || props.connection?.host || t('remoteDesktopModal.titlePlaceholder') }}
</h3>
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-1">
<span class="text-xs px-2 py-0.5 rounded"
:class="{
'bg-yellow-200 text-yellow-800': connectionStatus === 'connecting',
@@ -471,6 +632,13 @@ const computedModalStyle = computed(() => {
}">
{{ t('remoteDesktopModal.status.' + connectionStatus) }}
</span>
<button
@click="minimizeModal"
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
:title="t('common.minimize')"
>
<i class="fas fa-window-minimize fa-sm"></i>
</button>
<button
@click="closeModal"
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
@@ -491,7 +659,7 @@ const computedModalStyle = computed(() => {
<i v-else class="fas fa-exclamation-triangle fa-2x mb-3 text-red-400"></i>
<p class="text-sm">{{ statusMessage }}</p>
<button v-if="connectionStatus === 'error'"
@click="() => connectRdp()"
@click="() => handleConnection()"
class="mt-4 px-3 py-1 bg-primary text-white rounded text-xs hover:bg-primary-dark">
{{ t('common.retry') }}
</button>
@@ -523,17 +691,23 @@ const computedModalStyle = computed(() => {
/>
<!-- 添加重新连接按钮 -->
<button
@click="connectRdp"
:disabled="connectionStatus === 'connecting'"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
:title="t('remoteDesktopModal.reconnectTooltip')"
@click="handleConnection"
:disabled="connectionStatus === 'connecting'"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
:title="t('remoteDesktopModal.reconnectTooltip')"
>
{{ t('common.reconnect') }}
{{ t('common.reconnect') }}
</button>
</div>
</div>
</div>
</div>
<!-- Resize Handle -->
<div
class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10 bg-transparent hover:bg-primary-dark hover:bg-opacity-30"
title="Resize"
@mousedown.stop="initResize"
></div>
</div>
</div>
</template>
<style scoped>
.rdp-display-container {
@@ -0,0 +1,670 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useSettingsStore } from '../stores/settings.store';
import { useConnectionsStore } from '../stores/connections.store';
// @ts-ignore - guacamole-common-js
import Guacamole from 'guacamole-common-js';
import type { ConnectionInfo } from '../stores/connections.store';
const { t } = useI18n();
const settingsStore = useSettingsStore();
const props = defineProps<{
connection: ConnectionInfo | null;
}>();
const emit = defineEmits(['close']);
let saveWidthTimeout: ReturnType<typeof setTimeout> | null = null;
let saveHeightTimeout: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_DELAY = 500; // ms
const vncDisplayRef = ref<HTMLDivElement | null>(null);
const vncContainerRef = ref<HTMLDivElement | null>(null);
const guacClient = ref<any | null>(null);
const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'error'>('disconnected');
const isResizing = ref(false);
const resizeStartX = ref(0);
const resizeStartY = ref(0);
const initialModalWidthForResize = ref(0);
const initialModalHeightForResize = ref(0);
const statusMessage = ref('');
const keyboard = ref<any | null>(null);
const mouse = ref<any | null>(null);
// Initialize desiredModalWidth and desiredModalHeight from store or defaults
const initialStoreWidth = settingsStore.settings.vncModalWidth
? parseInt(settingsStore.settings.vncModalWidth, 10)
: 1024;
const initialStoreHeight = settingsStore.settings.vncModalHeight
? parseInt(settingsStore.settings.vncModalHeight, 10)
: 768;
const MIN_MODAL_WIDTH = 800;
const MIN_MODAL_HEIGHT = 600;
const desiredModalWidth = ref(Math.max(MIN_MODAL_WIDTH, isNaN(initialStoreWidth) ? MIN_MODAL_WIDTH : initialStoreWidth));
const desiredModalHeight = ref(Math.max(MIN_MODAL_HEIGHT, isNaN(initialStoreHeight) ? MIN_MODAL_HEIGHT : initialStoreHeight));
const isKeyboardDisabledForInput = ref(false);
const isMinimized = ref(false);
const restoreButtonRef = ref<HTMLButtonElement | null>(null);
const isDraggingRestoreButton = ref(false);
const restoreButtonPosition = ref({ x: 16, y: window.innerHeight / 2 - 25 }); // 16px from left, vertically centered (25 is half of button height 50px)
let dragOffsetX = 0;
let dragOffsetY = 0;
let hasDragged = false;
let remoteDesktopWsBaseUrl: string; // Renamed for clarity
const LOCAL_BACKEND_URL_FOR_PROXY = 'ws://localhost:3001'; // Main backend's WebSocket for proxying
if (window.location.hostname === 'localhost') {
// For local development, VNC will also go through the main backend's proxy
remoteDesktopWsBaseUrl = `${LOCAL_BACKEND_URL_FOR_PROXY}/ws/rdp-proxy`; // Use the same RDP proxy path
} else {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHostAndPort = window.location.host;
// For deployed environments, assume the proxy is at /ws/rdp-proxy relative to the main backend
remoteDesktopWsBaseUrl = `${wsProtocol}//${wsHostAndPort}/ws/rdp-proxy`;
}
const handleConnection = async () => {
if (!props.connection || !vncDisplayRef.value) {
statusMessage.value = t('remoteDesktopModal.errors.missingInfo');
connectionStatus.value = 'error';
return;
}
while (vncDisplayRef.value.firstChild) {
vncDisplayRef.value.removeChild(vncDisplayRef.value.firstChild);
}
disconnectGuacamole();
connectionStatus.value = 'connecting';
statusMessage.value = t('remoteDesktopModal.status.fetchingToken');
try {
const connectionsStore = useConnectionsStore();
// Pass width and height to the token generation, backend will forward to gateway
const token = await connectionsStore.getVncSessionToken(props.connection.id, desiredModalWidth.value, desiredModalHeight.value);
if (!token) {
throw new Error('VNC Token not found from store action');
}
statusMessage.value = t('remoteDesktopModal.status.connectingWs');
// The backend proxy (/ws/rdp-proxy) expects token, width, height, dpi.
// For VNC, DPI is less critical but the proxy might expect it. Send a default or let backend handle.
// The backend's websocket.ts rdp-proxy handler now calculates DPI if not provided or uses a default.
// We need to ensure width and height are passed for the proxy to correctly forward.
const tunnelUrl = `${remoteDesktopWsBaseUrl}?token=${encodeURIComponent(token)}&width=${desiredModalWidth.value}&height=${desiredModalHeight.value}`;
// @ts-ignore
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
tunnel.onerror = (status: any) => {
const errorMessage = status.message || 'Unknown tunnel error';
const errorCode = status.code || 'N/A';
statusMessage.value = `${t('remoteDesktopModal.errors.tunnelError')} (${errorCode}): ${errorMessage}`;
connectionStatus.value = 'error';
disconnectGuacamole();
};
// @ts-ignore
guacClient.value = new Guacamole.Client(tunnel);
guacClient.value.keepAliveFrequency = 3000;
vncDisplayRef.value.appendChild(guacClient.value.getDisplay().getElement());
guacClient.value.onstatechange = (state: number) => {
let currentStatus = '';
let i18nKeyPart = 'unknownState';
switch (state) {
case 0: i18nKeyPart = 'idle'; currentStatus = 'disconnected'; break;
case 1: i18nKeyPart = 'connectingVnc'; currentStatus = 'connecting'; break;
case 2: i18nKeyPart = 'waiting'; currentStatus = 'connecting'; break;
case 3:
i18nKeyPart = 'connected';
currentStatus = 'connected';
setupInputListeners();
nextTick(() => {
const displayEl = guacClient.value?.getDisplay()?.getElement();
if (displayEl && typeof displayEl.focus === 'function') {
displayEl.focus();
}
// Sync size on connect
if (vncDisplayRef.value && guacClient.value) {
const displayWidth = vncDisplayRef.value.offsetWidth;
const displayHeight = vncDisplayRef.value.offsetHeight;
if (displayWidth > 0 && displayHeight > 0) {
console.log(`[VncModal] Initial resize on connect: ${displayWidth}x${displayHeight}`);
guacClient.value.sendSize(displayWidth, displayHeight);
}
}
});
setTimeout(() => {
nextTick(() => {
if (vncDisplayRef.value && guacClient.value) {
const canvases = vncDisplayRef.value.querySelectorAll('canvas');
canvases.forEach((canvas) => { canvas.style.zIndex = '999'; });
}
});
}, 100);
break;
case 4: i18nKeyPart = 'disconnecting'; currentStatus = 'disconnected'; break;
case 5: i18nKeyPart = 'disconnected'; currentStatus = 'disconnected'; break;
}
statusMessage.value = t(`remoteDesktopModal.status.${i18nKeyPart}`, { state });
if (currentStatus) connectionStatus.value = currentStatus as 'disconnected' | 'connecting' | 'connected' | 'error';
};
guacClient.value.onerror = (status: any) => {
const errorMessage = status.message || 'Unknown client error';
statusMessage.value = `${t('remoteDesktopModal.errors.clientError')}: ${errorMessage}`;
connectionStatus.value = 'error';
disconnectGuacamole();
};
guacClient.value.connect('');
} catch (error: any) {
statusMessage.value = `${t('remoteDesktopModal.errors.connectionFailed')}: ${error.response?.data?.message || error.message || String(error)}`;
connectionStatus.value = 'error';
disconnectGuacamole();
}
};
const trySyncClipboardOnDisplayFocus = async () => {
if (!guacClient.value) {
return;
}
try {
const currentClipboardText = await navigator.clipboard.readText();
if (currentClipboardText && guacClient.value) {
// @ts-ignore
const stream = guacClient.value.createClipboardStream('text/plain');
// @ts-ignore
const writer = new Guacamole.StringWriter(stream);
writer.sendText(currentClipboardText);
writer.sendEnd();
console.log('[VncModal] Sent clipboard to VNC on display focus:', currentClipboardText.substring(0, 50) + (currentClipboardText.length > 50 ? '...' : ''));
}
} catch (err) {
// This error is expected if the document/tab is not focused when the VNC display element gets focus.
// Or if clipboard permissions are not granted.
if (err instanceof DOMException && err.name === 'NotAllowedError') {
// console.log('[VncModal] Clipboard read on display focus skipped: Document not focused or permission denied.');
} else {
console.warn('[VncModal] Could not read clipboard on display focus, or other error:', err);
}
}
};
const setupInputListeners = () => {
if (!guacClient.value || !vncDisplayRef.value) return;
try {
const displayEl = guacClient.value.getDisplay().getElement() as HTMLElement;
displayEl.tabIndex = 0;
const handleVncDisplayClick = () => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && (activeElement.id === 'modal-width' || activeElement.id === 'modal-height')) {
activeElement.blur();
}
// Ensure the VNC display element gets focus when clicked
if (displayEl && typeof displayEl.focus === 'function') {
displayEl.focus();
}
};
displayEl.addEventListener('click', handleVncDisplayClick);
const handleMouseEnter = () => { if (displayEl) displayEl.style.cursor = 'none'; };
const handleMouseLeave = () => { if (displayEl) displayEl.style.cursor = 'default'; };
displayEl.addEventListener('mouseenter', handleMouseEnter);
displayEl.addEventListener('mouseleave', handleMouseLeave);
// @ts-ignore
mouse.value = new Guacamole.Mouse(displayEl);
const display = guacClient.value.getDisplay();
display.showCursor(true);
const cursorLayer = display.getCursorLayer();
if (cursorLayer) {
const cursorElement = cursorLayer.getElement();
if (cursorElement) {
cursorElement.style.zIndex = '1000';
}
}
// @ts-ignore
mouse.value.onmousedown = mouse.value.onmouseup = mouse.value.onmousemove = (mouseState: any) => {
if (guacClient.value) {
guacClient.value.sendMouseState(mouseState);
}
};
// @ts-ignore
keyboard.value = new Guacamole.Keyboard(displayEl);
keyboard.value.onkeydown = (keysym: number) => {
if (guacClient.value && !isKeyboardDisabledForInput.value) {
guacClient.value.sendKeyEvent(1, keysym);
}
};
keyboard.value.onkeyup = (keysym: number) => {
if (guacClient.value && !isKeyboardDisabledForInput.value) {
guacClient.value.sendKeyEvent(0, keysym);
}
};
// Listen for host copy events to send to VNC
// document.addEventListener('copy', handleHostCopy); // Removed this
// displayEl.addEventListener('mouseenter', trySyncClipboardOnMouseEnter); // Changed to focus event
displayEl.addEventListener('focus', trySyncClipboardOnDisplayFocus);
} catch (inputError) {
console.error("Error setting up VNC input listeners:", inputError);
statusMessage.value = t('remoteDesktopModal.errors.inputError');
}
};
const removeInputListeners = () => {
// Remove host copy event listener
// document.removeEventListener('copy', handleHostCopy); // Removed this
if (guacClient.value) {
const displayEl = guacClient.value.getDisplay()?.getElement();
if (displayEl) {
// displayEl.removeEventListener('mouseenter', trySyncClipboardOnMouseEnter); // Changed to focus event
displayEl.removeEventListener('focus', trySyncClipboardOnDisplayFocus);
try {
if (displayEl) {
displayEl.style.cursor = 'default';
}
} catch (e) {
console.warn("Could not reset cursor on VNC display element:", e);
}
}
}
// The rest of the cleanup for keyboard and mouse can remain outside the guacClient.value check
// as they are independent refs.
if (keyboard.value) {
keyboard.value.onkeydown = null;
keyboard.value.onkeyup = null;
keyboard.value = null;
}
if (mouse.value) {
mouse.value.onmousedown = null;
mouse.value.onmouseup = null;
mouse.value.onmousemove = null;
mouse.value = null;
}
};
const disableVncKeyboard = () => {
isKeyboardDisabledForInput.value = true;
};
const enableVncKeyboard = () => {
isKeyboardDisabledForInput.value = false;
nextTick(() => {
const displayEl = guacClient.value?.getDisplay()?.getElement();
if (displayEl && typeof displayEl.focus === 'function') {
displayEl.focus();
}
});
};
const minimizeModal = () => {
isMinimized.value = true;
};
const restoreModal = () => {
isMinimized.value = false;
};
const onRestoreButtonMouseDown = (event: MouseEvent) => {
if (!restoreButtonRef.value) return;
hasDragged = false; // Reset drag flag
isDraggingRestoreButton.value = true;
dragOffsetX = event.clientX - restoreButtonRef.value.getBoundingClientRect().left;
dragOffsetY = event.clientY - restoreButtonRef.value.getBoundingClientRect().top;
// Prevent text selection while dragging
event.preventDefault();
document.addEventListener('mousemove', onRestoreButtonMouseMove);
document.addEventListener('mouseup', onRestoreButtonMouseUp);
};
const onRestoreButtonMouseMove = (event: MouseEvent) => {
if (!isDraggingRestoreButton.value) return;
hasDragged = true; // Set drag flag if mouse moves
let newX = event.clientX - dragOffsetX;
let newY = event.clientY - dragOffsetY;
// Constrain movement within viewport
const buttonWidth = 50; // As defined in style
const buttonHeight = 50; // As defined in style
newX = Math.max(0, Math.min(newX, window.innerWidth - buttonWidth));
newY = Math.max(0, Math.min(newY, window.innerHeight - buttonHeight));
restoreButtonPosition.value = { x: newX, y: newY };
};
const onRestoreButtonMouseUp = () => {
isDraggingRestoreButton.value = false;
document.removeEventListener('mousemove', onRestoreButtonMouseMove);
document.removeEventListener('mouseup', onRestoreButtonMouseUp);
// Click event will fire after mouseup. If we dragged, we don't want click to restore.
// The handleClickRestoreButton will check hasDragged.
};
const handleClickRestoreButton = () => {
if (!hasDragged) {
restoreModal();
}
// Reset for next interaction
hasDragged = false;
};
const disconnectGuacamole = () => {
removeInputListeners();
isKeyboardDisabledForInput.value = false;
if (guacClient.value) {
guacClient.value.disconnect();
guacClient.value = null;
}
if (vncDisplayRef.value) {
while (vncDisplayRef.value.firstChild) {
vncDisplayRef.value.removeChild(vncDisplayRef.value.firstChild);
}
}
if (connectionStatus.value !== 'error') {
connectionStatus.value = 'disconnected';
statusMessage.value = t('remoteDesktopModal.status.disconnected');
}
};
const closeModal = () => {
disconnectGuacamole();
emit('close');
};
watch(desiredModalWidth, (newWidth, oldWidth) => {
if (newWidth === oldWidth && typeof newWidth === 'number' && typeof oldWidth === 'number') {
return;
}
const validatedWidth = Math.max(MIN_MODAL_WIDTH, Number(newWidth) || MIN_MODAL_WIDTH);
if (validatedWidth !== Number(newWidth)) {
nextTick(() => {
desiredModalWidth.value = validatedWidth;
});
}
if (saveWidthTimeout) clearTimeout(saveWidthTimeout);
saveWidthTimeout = setTimeout(() => {
if (String(validatedWidth) !== settingsStore.settings.vncModalWidth) {
settingsStore.updateSetting('vncModalWidth', String(validatedWidth));
} else {
// console.log(`[VncModal] - ${validatedWidth} `);
}
}, DEBOUNCE_DELAY);
});
watch(desiredModalHeight, (newHeight, oldHeight) => {
if (newHeight === oldHeight && typeof newHeight === 'number' && typeof oldHeight === 'number') {
// console.log(`[VncModal] (${newHeight}) `);
return;
}
// console.log(`[VncModal] desiredModalHeight : ${oldHeight} -> ${newHeight}`);
const validatedHeight = Math.max(MIN_MODAL_HEIGHT, Number(newHeight) || MIN_MODAL_HEIGHT);
if (validatedHeight !== Number(newHeight)) {
nextTick(() => {
desiredModalHeight.value = validatedHeight;
});
}
if (saveHeightTimeout) clearTimeout(saveHeightTimeout);
saveHeightTimeout = setTimeout(() => {
// console.log(`[VncModal] - : ${validatedHeight}`);
if (String(validatedHeight) !== settingsStore.settings.vncModalHeight) {
settingsStore.updateSetting('vncModalHeight', String(validatedHeight));
} else {
// console.log(`[VncModal] - ${validatedHeight} `);
}
}, DEBOUNCE_DELAY);
});
onMounted(() => {
if (props.connection) {
nextTick(async () => {
await handleConnection();
});
} else {
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
connectionStatus.value = 'error';
}
});
onUnmounted(() => {
disconnectGuacamole();
document.removeEventListener('mousemove', onRestoreButtonMouseMove);
document.removeEventListener('mouseup', onRestoreButtonMouseUp);
// Clean up resize listeners if component is unmounted while resizing
if (isResizing.value) {
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
}
});
watch(() => props.connection, (newConnection, oldConnection) => {
if (newConnection && newConnection.id !== oldConnection?.id) {
nextTick(async () => {
await handleConnection();
});
} else if (!newConnection) {
disconnectGuacamole();
statusMessage.value = t('remoteDesktopModal.errors.noConnection');
connectionStatus.value = 'error';
}
});
const computedModalStyle = computed(() => {
const actualWidth = Math.max(MIN_MODAL_WIDTH, desiredModalWidth.value);
const actualHeight = Math.max(MIN_MODAL_HEIGHT, desiredModalHeight.value);
return {
width: `${actualWidth}px`,
height: `${actualHeight}px`,
};
});
watchEffect(() => {
// computedModalStyle effect
const currentStyle = computedModalStyle.value;
if (guacClient.value && connectionStatus.value === 'connected' && vncDisplayRef.value) {
// 使 nextTick DOM vncDisplayRef currentStyle
nextTick(() => {
if (vncDisplayRef.value && guacClient.value) { // nextTick
const displayWidth = vncDisplayRef.value.offsetWidth;
const displayHeight = vncDisplayRef.value.offsetHeight;
if (displayWidth > 0 && displayHeight > 0) {
console.log(`[VncModal] Resizing VNC display to: ${displayWidth}x${displayHeight} due to style change.`);
guacClient.value.sendSize(displayWidth, displayHeight);
}
}
});
}
});
const initResize = (event: MouseEvent) => {
isResizing.value = true;
resizeStartX.value = event.clientX;
resizeStartY.value = event.clientY;
initialModalWidthForResize.value = desiredModalWidth.value;
initialModalHeightForResize.value = desiredModalHeight.value;
document.addEventListener('mousemove', doResize);
document.addEventListener('mouseup', stopResize);
event.preventDefault(); // Prevent text selection or other default browser actions
};
const doResize = (event: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = event.clientX - resizeStartX.value;
const deltaY = event.clientY - resizeStartY.value;
let newWidth = initialModalWidthForResize.value + deltaX;
let newHeight = initialModalHeightForResize.value + deltaY;
// Apply minimum size constraints
newWidth = Math.max(MIN_MODAL_WIDTH, newWidth);
newHeight = Math.max(MIN_MODAL_HEIGHT, newHeight);
desiredModalWidth.value = newWidth;
desiredModalHeight.value = newHeight;
};
const stopResize = () => {
if (!isResizing.value) return;
isResizing.value = false;
document.removeEventListener('mousemove', doResize);
document.removeEventListener('mouseup', stopResize);
// The existing watchEffect for computedModalStyle will handle Guacamole resize
};
</script>
<template>
<div
:class="[
'fixed inset-0 z-50 flex items-center justify-center p-4',
isMinimized ? '' : 'bg-overlay',
isMinimized ? 'pointer-events-none' : '' //
]"
>
<button
ref="restoreButtonRef"
v-if="isMinimized"
@mousedown="onRestoreButtonMouseDown"
@click="handleClickRestoreButton"
:style="{ left: `${restoreButtonPosition.x}px`, top: `${restoreButtonPosition.y}px`, width: '50px', height: '50px' }"
class="fixed z-[100] flex items-center justify-center bg-primary text-white rounded-full shadow-lg hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50 pointer-events-auto cursor-grab active:cursor-grabbing"
:title="t('common.restore')"
>
<i class="fas fa-window-restore fa-lg"></i>
</button>
<div
v-show="!isMinimized"
:style="computedModalStyle"
class="bg-background text-foreground rounded-lg shadow-xl flex flex-col overflow-hidden border border-border pointer-events-auto relative"
>
<div class="flex items-center justify-between p-3 border-b border-border flex-shrink-0">
<h3 class="text-base font-semibold truncate">
<i class="fas fa-plug mr-2 text-text-secondary"></i>
{{ t('vncModal.title') }} - {{ props.connection?.name || props.connection?.host || t('remoteDesktopModal.titlePlaceholder') }}
</h3>
<div class="flex items-center space-x-1">
<span class="text-xs px-2 py-0.5 rounded"
:class="{
'bg-yellow-200 text-yellow-800': connectionStatus === 'connecting',
'bg-green-200 text-green-800': connectionStatus === 'connected',
'bg-red-200 text-red-800': connectionStatus === 'error',
'bg-gray-200 text-gray-800': connectionStatus === 'disconnected'
}">
{{ t('remoteDesktopModal.status.' + connectionStatus) }}
</span>
<button
@click="minimizeModal"
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
:title="t('common.minimize')"
>
<i class="fas fa-window-minimize fa-sm"></i>
</button>
<button
@click="closeModal"
class="text-text-secondary hover:text-foreground transition-colors duration-150 p-1 rounded hover:bg-hover"
:title="t('common.close')"
>
<i class="fas fa-times fa-lg"></i>
</button>
</div>
</div>
<div ref="vncContainerRef" class="relative bg-black overflow-hidden flex-1">
<div ref="vncDisplayRef" class="vnc-display-container w-full h-full">
</div>
<div v-if="connectionStatus === 'connecting' || connectionStatus === 'error'"
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-75 text-white p-4 z-10">
<div class="text-center">
<i v-if="connectionStatus === 'connecting'" class="fas fa-spinner fa-spin fa-2x mb-3"></i>
<i v-else class="fas fa-exclamation-triangle fa-2x mb-3 text-red-400"></i>
<p class="text-sm">{{ statusMessage }}</p>
<button v-if="connectionStatus === 'error'"
@click="() => handleConnection()"
class="mt-4 px-3 py-1 bg-primary text-white rounded text-xs hover:bg-primary-dark">
{{ t('common.retry') }}
</button>
</div>
</div>
</div>
<div class="p-2 border-t border-border flex-shrink-0 text-xs text-text-secondary bg-header flex items-center justify-end">
<div class="flex items-center space-x-2 flex-wrap gap-y-1">
<label for="modal-width" class="text-xs ml-2">{{ t('common.width') }}:</label>
<input
id="modal-width"
type="number"
v-model.number="desiredModalWidth"
step="10"
class="w-16 px-1 py-0.5 text-xs border border-border rounded bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
@focus="disableVncKeyboard"
@blur="enableVncKeyboard"
/>
<label for="modal-height" class="text-xs">{{ t('common.height') }}:</label>
<input
id="modal-height"
type="number"
v-model.number="desiredModalHeight"
step="10"
class="w-16 px-1 py-0.5 text-xs border border-border rounded bg-input text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
@focus="disableVncKeyboard"
@blur="enableVncKeyboard"
/>
<button
@click="handleConnection"
:disabled="connectionStatus === 'connecting'"
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
:title="t('remoteDesktopModal.reconnectTooltip')"
>
{{ t('common.reconnect') }}
</button>
</div>
</div>
<!-- Resize Handle -->
<div
class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10 bg-transparent hover:bg-primary-dark hover:bg-opacity-30"
title="Resize"
@mousedown.stop="initResize"
></div>
</div>
</div>
</template>
<style scoped>
.vnc-display-container {
overflow: hidden;
position: relative;
}
.vnc-display-container :deep(div) {
}
.vnc-display-container :deep(canvas) {
z-index: 999;
}
</style>
@@ -306,15 +306,8 @@ const handleConnect = (connectionId: number, event?: MouseEvent | KeyboardEvent)
closeContextMenu(); //
if (connection.type === 'RDP') {
console.log(`[WkspConnList] RDP connection clicked (ID: ${connectionId}). Calling sessionStore.openRdpModal.`);
// --- Store Action ---
sessionStore.openRdpModal(connection);
} else {
console.log(`[WkspConnList] Non-RDP connection clicked (ID: ${connectionId}, Type: ${connection.type}). Emitting connect-request.`);
// RDP
emit('connect-request', connectionId);
}
// connect-request sessionStore.handleConnectRequest
emit('connect-request', connectionId);
};
// --- closeRdpModal ---
@@ -690,7 +683,7 @@ const cancelEditingTag = () => {
@click.right.prevent
@contextmenu.prevent="showContextMenu($event, conn)"
>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : (conn.type === 'VNC' ? 'fa-plug' : 'fa-server'), 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
<span class="overflow-hidden text-ellipsis whitespace-nowrap flex-grow text-sm" :title="conn.name || conn.host">
{{ conn.name || conn.host }}
</span>
@@ -710,7 +703,7 @@ const cancelEditingTag = () => {
@click.right.prevent
@contextmenu.prevent="showContextMenu($event, conn)"
>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : (conn.type === 'VNC' ? 'fa-chalkboard' : 'fa-server'), 'mr-2.5 w-4 text-center text-text-secondary group-hover:text-primary', { 'text-white': conn.id === highlightedConnectionId }]"></i>
<span class="overflow-hidden text-ellipsis whitespace-nowrap flex-grow text-sm" :title="conn.name || conn.host">
{{ conn.name || conn.host }}
</span>
+10 -1
View File
@@ -141,6 +141,7 @@
"password": "Password:",
"privateKey": "Private Key:",
"passphrase": "Passphrase:",
"vncPassword": "VNC Password",
"optional": "Optional",
"confirm": "Confirm Add",
"adding": "Adding...",
@@ -150,6 +151,7 @@
"errorPrivateKeyRequired": "Private key is required for key authentication.",
"errorPasswordRequiredOnSwitch": "Password is required when switching to password authentication.",
"errorPrivateKeyRequiredOnSwitch": "Private key is required when switching to key authentication.",
"errorVncPasswordRequired": "VNC password is required.",
"errorPort": "Port must be between 1 and 65535.",
"errorAdd": "Failed to add connection: {error}",
"titleEdit": "Edit Connection",
@@ -165,6 +167,7 @@
"connectionType": "Connection Type:",
"typeSsh": "SSH",
"typeRdp": "RDP",
"typeVnc": "VNC",
"sectionBasic": "Basic Information",
"sectionAuth": "Authentication",
"sectionAdvanced": "Advanced Options",
@@ -812,7 +815,9 @@
"reconnect": "Reconnect",
"retry": "Retry",
"sortAscending": "Ascending",
"sortDescending": "Descending"
"sortDescending": "Descending",
"restore": "Restore",
"minimize": "Minimize"
},
"layoutConfigurator": {
"title": "Layout Configurator",
@@ -897,6 +902,7 @@
"connectingWs": "Connecting WebSocket...",
"idle": "Idle",
"connectingRdp": "Connecting Remote Desktop...",
"connectingVnc": "Connecting VNC...",
"waiting": "Waiting for server response...",
"connected": "Connected",
"disconnecting": "Disconnecting...",
@@ -916,6 +922,9 @@
},
"reconnectTooltip": "Reconnect to the remote desktop"
},
"vncModal": {
"title": "VNC Session"
},
"commandInputBar": {
"placeholder": "Enter command and press Enter to send...",
"searchPlaceholder": "Search in terminal...",
+6 -1
View File
@@ -86,7 +86,9 @@
"sortAscending": "昇順",
"sortDescending": "降順",
"testing": "テスト中...",
"width": "幅"
"width": "幅",
"restore": "元に戻す",
"minimize": "最小化"
},
"connections": {
"actions": {
@@ -589,6 +591,9 @@
"title": "リモートデスクトップ",
"titlePlaceholder": "リモートデスクトップ接続"
},
"vncModal": {
"title": "VNCセッション"
},
"settings": {
"appearance": {
"customizeButton": "外観をカスタマイズ",
+11 -2
View File
@@ -141,6 +141,7 @@
"password": "密码:",
"privateKey": "私钥:",
"passphrase": "私钥密码:",
"vncPassword": "VNC 密码:",
"optional": "可选",
"confirm": "确认添加",
"adding": "正在添加...",
@@ -150,6 +151,7 @@
"errorPrivateKeyRequired": "使用密钥认证时,私钥为必填项。",
"errorPasswordRequiredOnSwitch": "切换到密码认证时,密码为必填项。",
"errorPrivateKeyRequiredOnSwitch": "切换到密钥认证时,私钥为必填项。",
"errorVncPasswordRequired": "VNC 密码是必填项。",
"errorPort": "端口号必须在 1 到 65535 之间。",
"errorAdd": "添加连接失败: {error}",
"titleEdit": "编辑连接",
@@ -165,6 +167,7 @@
"connectionType": "连接类型",
"typeSsh": "SSH",
"typeRdp": "RDP",
"typeVnc": "VNC",
"sectionBasic": "基本信息",
"sectionAuth": "认证信息",
"sectionAdvanced": "高级选项",
@@ -813,7 +816,9 @@
"reconnect": "重新连接",
"retry": "重试",
"sortAscending": "升序",
"sortDescending": "降序"
"sortDescending": "降序",
"restore": "还原",
"minimize": "最小化"
},
"layoutConfigurator": {
"title": "布局管理器",
@@ -900,8 +905,9 @@
"connectingWs": "正在连接 WebSocket...",
"idle": "空闲",
"connectingRdp": "正在连接远程桌面...",
"connectingVnc": "正在连接 VNC...",
"waiting": "等待服务器响应...",
"connecting": "连接中...",
"connecting": "连接中...",
"error": "错误",
"connected": "已连接",
"disconnecting": "正在断开连接...",
@@ -919,6 +925,9 @@
},
"reconnectTooltip": "重新连接到远程桌面"
},
"vncModal": {
"title": "VNC 会话"
},
"commandInputBar": {
"placeholder": "在此输入命令后按 Enter 发送到终端...",
"searchPlaceholder": "在终端中搜索...",
@@ -5,7 +5,7 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
export interface ConnectionInfo {
id: number;
name: string;
type: 'SSH' | 'RDP'; // Use uppercase to match backend data
type: 'SSH' | 'RDP' | 'VNC'; // Use uppercase to match backend data
host: string;
port: number;
username: string;
@@ -17,6 +17,7 @@ export interface ConnectionInfo {
updated_at: number;
last_connected_at: number | null;
notes?: string | null; // 新增备注字段
vncPassword?: string; // VNC specific password
}
// 定义 Store State 的接口
@@ -87,14 +88,15 @@ export const useConnectionsStore = defineStore('connections', {
// 更新参数类型以接受新的认证字段
async addConnection(newConnectionData: {
name: string;
type: 'SSH' | 'RDP'; // Use uppercase
type: 'SSH' | 'RDP' | 'VNC'; // Use uppercase
host: string;
port: number;
username: string;
auth_method: 'password' | 'key';
password?: string;
private_key?: string;
passphrase?: string;
auth_method: 'password' | 'key'; // SSH specific
password?: string; // SSH password or general password
private_key?: string; // SSH specific
passphrase?: string; // SSH specific
vncPassword?: string; // VNC specific password
proxy_id?: number | null;
tag_ids?: number[]; // 新增:允许传入 tag_ids
}) {
@@ -122,8 +124,8 @@ export const useConnectionsStore = defineStore('connections', {
// 更新连接 Action
// 更新参数类型以包含 proxy_id 和 tag_ids
// Update parameter type to include 'type'
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP'; password?: string; private_key?: string; passphrase?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
// Update parameter type to include 'type' and VNC fields
async updateConnection(connectionId: number, updatedData: Partial<Omit<ConnectionInfo, 'id' | 'created_at' | 'updated_at' | 'last_connected_at'> & { type?: 'SSH' | 'RDP' | 'VNC'; password?: string; private_key?: string; passphrase?: string; vncPassword?: string; proxy_id?: number | null; tag_ids?: number[] }>) {
this.isLoading = true;
this.error = null;
try {
@@ -282,5 +284,38 @@ export const useConnectionsStore = defineStore('connections', {
this.isLoading = false;
}
},
// +++ 新增:获取 VNC 会话令牌 +++
async getVncSessionToken(connectionId: number, width?: number, height?: number): Promise<string | null> {
// this.isLoading = true; // 考虑是否需要独立的加载状态,或者由调用方处理
// this.error = null;
try {
let apiUrl = `/connections/${connectionId}/vnc-session`;
const params = new URLSearchParams();
if (width !== undefined) {
params.append('width', String(width));
}
if (height !== undefined) {
params.append('height', String(height));
}
const queryString = params.toString();
if (queryString) {
apiUrl += `?${queryString}`;
}
// 调用后端 API POST /connections/:id/vnc-session (现在带有可选的 width/height 查询参数)
const response = await apiClient.post<{ token: string }>(apiUrl);
return response.data.token;
} catch (err: any) {
console.error(`获取 VNC 会话令牌失败 (连接 ID: ${connectionId}):`, err);
// this.error = err.response?.data?.message || err.message || '获取 VNC 会话令牌时发生未知错误。';
if (err.response?.status === 401) {
console.warn('未授权,需要登录才能获取 VNC 会话令牌。');
}
// 对于这种一次性获取数据的操作,错误通常由调用方处理并显示给用户
throw err; // 重新抛出错误,让调用方处理
} finally {
// this.isLoading = false;
}
},
},
});
@@ -70,10 +70,10 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
// +++ 修改:从后端加载配置(包括快捷键) +++
async function loadConfigurationFromBackend() {
const apiUrl = '/api/v1/settings/focus-switcher-sequence'; // 假设 API 端点不变,但返回结构改变
console.log(`[FocusSwitcherStore] Attempting to load full configuration (sequence & shortcuts) from backend via: ${apiUrl}`);
// console.log(`[FocusSwitcherStore] Attempting to load full configuration (sequence & shortcuts) from backend via: ${apiUrl}`);
try {
const response = await fetch(apiUrl);
console.log(`[FocusSwitcherStore] Received response from ${apiUrl}. Status: ${response.status}`);
// console.log(`[FocusSwitcherStore] Received response from ${apiUrl}. Status: ${response.status}`);
if (!response.ok) {
console.error(`[FocusSwitcherStore] HTTP error from ${apiUrl}. Status: ${response.status}`);
@@ -82,7 +82,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
// *** 假设后端返回 FocusSwitcherFullConfig 结构 ***
const loadedFullConfig: FocusSwitcherFullConfig = await response.json();
console.log(`[FocusSwitcherStore] Raw JSON received from backend:`, JSON.stringify(loadedFullConfig));
// console.log(`[FocusSwitcherStore] Raw JSON received from backend:`, JSON.stringify(loadedFullConfig));
// --- 验证和设置 ---
const availableIds = new Set(availableInputs.value.map(input => input.id));
@@ -90,7 +90,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
// 验证 sequence
if (Array.isArray(loadedFullConfig?.sequence) && loadedFullConfig.sequence.every(id => typeof id === 'string' && availableIds.has(id))) {
sequenceOrder.value = loadedFullConfig.sequence;
console.log('[FocusSwitcherStore] Successfully loaded and set sequenceOrder:', JSON.stringify(sequenceOrder.value));
// console.log('[FocusSwitcherStore] Successfully loaded and set sequenceOrder:', JSON.stringify(sequenceOrder.value));
} else {
console.warn('[FocusSwitcherStore] Invalid or missing sequence in loaded config. Resetting to empty array.');
sequenceOrder.value = [];
@@ -113,7 +113,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
}
}
itemConfigs.value = validConfigs;
console.log('[FocusSwitcherStore] Successfully loaded and set itemConfigs:', JSON.stringify(itemConfigs.value));
// console.log('[FocusSwitcherStore] Successfully loaded and set itemConfigs:', JSON.stringify(itemConfigs.value));
} else {
console.warn('[FocusSwitcherStore] Invalid or missing shortcuts in loaded config. Resetting to empty object.');
itemConfigs.value = {};
@@ -123,20 +123,20 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
console.error(`[FocusSwitcherStore] Failed to load or parse configuration from backend (${apiUrl}):`, error);
sequenceOrder.value = [];
itemConfigs.value = {};
console.log('[FocusSwitcherStore] Reset sequenceOrder and itemConfigs due to loading error.');
// console.log('[FocusSwitcherStore] Reset sequenceOrder and itemConfigs due to loading error.');
}
}
async function saveConfigurationToBackend() {
const apiUrl = '/api/v1/settings/focus-switcher-sequence'; // 假设 API 端点不变,但接受结构改变
console.log(`[FocusSwitcherStore] Attempting to save full configuration (sequence & shortcuts) to backend via PUT: ${apiUrl}`);
// console.log(`[FocusSwitcherStore] Attempting to save full configuration (sequence & shortcuts) to backend via PUT: ${apiUrl}`);
try {
// *** 构造 FocusSwitcherFullConfig 结构发送给后端 ***
const configToSave: FocusSwitcherFullConfig = {
sequence: sequenceOrder.value,
shortcuts: itemConfigs.value,
};
console.log('[FocusSwitcherStore] Full configuration data to save:', JSON.stringify(configToSave));
// console.log('[FocusSwitcherStore] Full configuration data to save:', JSON.stringify(configToSave));
const response = await fetch(apiUrl, {
method: 'PUT',
headers: {
@@ -145,7 +145,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
},
body: JSON.stringify(configToSave), // *** 发送包含 sequence 和 shortcuts 的对象 ***
});
console.log(`[FocusSwitcherStore] Received response from PUT ${apiUrl}. Status: ${response.status}`);
// console.log(`[FocusSwitcherStore] Received response from PUT ${apiUrl}. Status: ${response.status}`);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
@@ -154,7 +154,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
}
const result = await response.json();
console.log('[FocusSwitcherStore] Configuration successfully saved to backend. Response message:', result.message);
// console.log('[FocusSwitcherStore] Configuration successfully saved to backend. Response message:', result.message);
} catch (error) {
console.error(`[FocusSwitcherStore] Failed to save configuration to backend (${apiUrl}):`, error);
// Notify user of failure
@@ -164,17 +164,17 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
function triggerTerminalSearchActivation() {
activateTerminalSearchTrigger.value++;
console.log('[FocusSwitcherStore] Triggering Terminal search activation.');
// console.log('[FocusSwitcherStore] Triggering Terminal search activation.');
}
function triggerFileManagerSearchActivation() {
activateFileManagerSearchTrigger.value++;
console.log('[FocusSwitcherStore] Triggering FileManager search activation.');
// console.log('[FocusSwitcherStore] Triggering FileManager search activation.');
}
function toggleConfigurator(visible?: boolean) {
isConfiguratorVisible.value = visible === undefined ? !isConfiguratorVisible.value : visible;
console.log(`[FocusSwitcherStore] Configurator visibility set to: ${isConfiguratorVisible.value}`);
// console.log(`[FocusSwitcherStore] Configurator visibility set to: ${isConfiguratorVisible.value}`);
}
// --- 移除旧的 loadConfiguration ---
@@ -193,13 +193,13 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
// +++ 修改:更新完整配置(包括顺序和所有快捷键) +++
function updateConfiguration(newFullConfig: FocusSwitcherFullConfig) {
console.log('[FocusSwitcherStore] updateConfiguration called with new full configuration:', JSON.stringify(newFullConfig));
// console.log('[FocusSwitcherStore] updateConfiguration called with new full configuration:', JSON.stringify(newFullConfig));
const availableIds = new Set(availableInputs.value.map(input => input.id));
// 更新 sequenceOrder (过滤无效 ID)
if (Array.isArray(newFullConfig?.sequence)) {
sequenceOrder.value = newFullConfig.sequence.filter(id => availableIds.has(id));
console.log('[FocusSwitcherStore] sequenceOrder updated locally to:', JSON.stringify(sequenceOrder.value));
// console.log('[FocusSwitcherStore] sequenceOrder updated locally to:', JSON.stringify(sequenceOrder.value));
} else {
console.warn('[FocusSwitcherStore] Invalid sequence provided in updateConfiguration. Keeping existing sequence.');
}
@@ -218,7 +218,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
}
}
itemConfigs.value = validConfigs;
console.log('[FocusSwitcherStore] itemConfigs updated locally to:', JSON.stringify(itemConfigs.value));
// console.log('[FocusSwitcherStore] itemConfigs updated locally to:', JSON.stringify(itemConfigs.value));
} else {
console.warn('[FocusSwitcherStore] Invalid shortcuts provided in updateConfiguration. Keeping existing configs.');
}
@@ -238,7 +238,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
const actions = registeredActions.value.get(id) || [];
actions.push(action);
registeredActions.value.set(id, actions);
console.log(`[FocusSwitcherStore] Registered focus action for ID: ${id}. Total actions for this ID: ${actions.length}`);
// console.log(`[FocusSwitcherStore] Registered focus action for ID: ${id}. Total actions for this ID: ${actions.length}`);
// 返回一个用于注销此特定动作的函数
const unregister = () => {
@@ -247,11 +247,11 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
const index = currentActions.indexOf(action);
if (index > -1) {
currentActions.splice(index, 1);
console.log(`[FocusSwitcherStore] Unregistered a focus action for ID: ${id}. Remaining actions: ${currentActions.length}`);
// console.log(`[FocusSwitcherStore] Unregistered a focus action for ID: ${id}. Remaining actions: ${currentActions.length}`);
// 如果数组为空,可以从 Map 中移除该 ID
if (currentActions.length === 0) {
registeredActions.value.delete(id);
console.log(`[FocusSwitcherStore] Removed ID ${id} from registeredActions map as it has no more actions.`);
// console.log(`[FocusSwitcherStore] Removed ID ${id} from registeredActions map as it has no more actions.`);
}
} else {
console.warn(`[FocusSwitcherStore] Attempted to unregister an action for ID ${id} that was not found.`);
@@ -269,7 +269,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
// 修改:统一的聚焦目标 Action,现在迭代 Map 中的动作数组
async function focusTarget(id: string): Promise<boolean> {
console.log(`[FocusSwitcherStore] Attempting to focus target ID: ${id}`);
// console.log(`[FocusSwitcherStore] Attempting to focus target ID: ${id}`);
const actions = registeredActions.value.get(id);
if (!actions || actions.length === 0) {
@@ -277,7 +277,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
return false;
}
console.log(`[FocusSwitcherStore] Found ${actions.length} action(s) for ID: ${id}. Iterating...`);
// console.log(`[FocusSwitcherStore] Found ${actions.length} action(s) for ID: ${id}. Iterating...`);
for (const action of actions) {
try {
@@ -286,14 +286,14 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
if (result === true) {
// 如果动作返回 true,表示成功聚焦,停止迭代并返回 true
console.log(`[FocusSwitcherStore] Successfully focused ${id} via one of its actions.`);
// console.log(`[FocusSwitcherStore] Successfully focused ${id} via one of its actions.`);
return true;
} else if (result === false) {
// 如果动作返回 false,表示尝试但失败,记录日志并继续下一个动作
console.log(`[FocusSwitcherStore] An action for ${id} returned false (failed). Trying next action if available.`);
// console.log(`[FocusSwitcherStore] An action for ${id} returned false (failed). Trying next action if available.`);
} else if (result === undefined) {
// 如果动作返回 undefined,表示跳过(例如非活动实例),记录日志并继续下一个动作
console.log(`[FocusSwitcherStore] An action for ${id} returned undefined (skipped). Trying next action if available.`);
// console.log(`[FocusSwitcherStore] An action for ${id} returned undefined (skipped). Trying next action if available.`);
}
// 如果 result 是其他值,也视为跳过或未处理
@@ -304,7 +304,7 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
}
// 如果遍历完所有动作都没有成功聚焦 (没有返回 true)
console.log(`[FocusSwitcherStore] All actions for ${id} executed, but none returned true. Focus failed.`);
// console.log(`[FocusSwitcherStore] All actions for ${id} executed, but none returned true. Focus failed.`);
// 尝试激活搜索框(如果适用),这里的逻辑可能需要重新审视,
// 因为激活应该由返回 false 的动作内部触发,或者由调用 focusTarget 的地方处理
if (id === 'fileManagerSearch') {
@@ -392,9 +392,9 @@ export const useFocusSwitcherStore = defineStore('focusSwitcher', () => {
// --- Initialization ---
// Store 创建时自动从后端加载配置
console.log('[FocusSwitcherStore] Initializing store and scheduling loadConfigurationFromBackend...'); // 使用新名称
// console.log('[FocusSwitcherStore] Initializing store and scheduling loadConfigurationFromBackend...'); // 使用新名称
nextTick(() => {
console.log('[FocusSwitcherStore] nextTick triggered, calling loadConfigurationFromBackend.'); // 使用新名称
// console.log('[FocusSwitcherStore] nextTick triggered, calling loadConfigurationFromBackend.'); // 使用新名称
loadConfigurationFromBackend(); // 调用重命名后的加载函数
});
+28 -4
View File
@@ -134,6 +134,10 @@ export const useSessionStore = defineStore('session', () => {
const isRdpModalOpen = ref(false);
const rdpConnectionInfo = ref<ConnectionInfo | null>(null);
// --- VNC Modal State ---
const isVncModalOpen = ref(false);
const vncConnectionInfo = ref<ConnectionInfo | null>(null);
// --- Getters ---
const sessionTabs = computed(() => {
return Array.from(sessions.value.values()).map(session => ({
@@ -409,11 +413,14 @@ export const useSessionStore = defineStore('session', () => {
* - Workspace
*/
const handleConnectRequest = (connection: ConnectionInfo) => {
console.log(`[SessionStore] handleConnectRequest called for connection: ${connection.name} (ID: ${connection.id}, Type: ${connection.type})`);
// console.log(`[SessionStore] handleConnectRequest called for connection: ${connection.name} (ID: ${connection.id}, Type: ${connection.type})`); // 保留原始日志或移除
if (connection.type === 'RDP') {
// RDP: 直接打开模态框
// RDP: 直接打开 RDP 模态框
openRdpModal(connection);
} else if (connection.type === 'VNC') {
// VNC: 直接打开 VNC 模态框
openVncModal(connection);
} else {
// 非 RDP (e.g., SSH): 处理会话和导航
const connIdStr = String(connection.id);
@@ -785,17 +792,30 @@ export const useSessionStore = defineStore('session', () => {
// --- RDP Modal Actions ---
const openRdpModal = (connection: ConnectionInfo) => {
console.log(`[SessionStore] Opening RDP modal for connection: ${connection.name} (ID: ${connection.id})`);
// console.log(`[SessionStore] Opening RDP modal for connection: ${connection.name} (ID: ${connection.id})`); // 保留原始日志或移除
rdpConnectionInfo.value = connection;
isRdpModalOpen.value = true;
};
const closeRdpModal = () => {
console.log('[SessionStore] Closing RDP modal.');
// console.log('[SessionStore] Closing RDP modal.'); // 保留原始日志或移除
isRdpModalOpen.value = false;
rdpConnectionInfo.value = null; // 清除连接信息
};
// --- VNC Modal Actions ---
const openVncModal = (connection: ConnectionInfo) => {
// console.log(`[SessionStore] Opening VNC modal for connection: ${connection.name} (ID: ${connection.id})`); // 保留原始日志或移除
vncConnectionInfo.value = connection;
isVncModalOpen.value = true;
};
const closeVncModal = () => {
// console.log('[SessionStore] Closing VNC modal.'); // 保留原始日志或移除
isVncModalOpen.value = false;
vncConnectionInfo.value = null; // 清除连接信息
};
/**
*
*/
@@ -815,6 +835,8 @@ export const useSessionStore = defineStore('session', () => {
activeSessionId,
isRdpModalOpen, // 导出 RDP 模态框状态
rdpConnectionInfo, // 导出 RDP 连接信息
isVncModalOpen, // 导出 VNC 模态框状态
vncConnectionInfo, // 导出 VNC 连接信息
// Getters
sessionTabs,
sessionTabsWithStatus, // 导出新的 getter
@@ -841,6 +863,8 @@ export const useSessionStore = defineStore('session', () => {
// --- RDP Modal Actions ---
openRdpModal, // 导出打开 RDP 模态框 Action
closeRdpModal, // 导出关闭 RDP 模态框 Action
openVncModal, // 导出打开 VNC 模态框 Action
closeVncModal, // 导出关闭 VNC 模态框 Action
// --- 命令输入框 Action ---
updateSessionCommandInput, // 导出更新命令输入 Action
};
+14 -1
View File
@@ -50,6 +50,8 @@ interface SettingsState {
timezone?: string; // NEW: 时区设置 (e.g., 'Asia/Shanghai', 'UTC')
rdpModalWidth?: string; // NEW: RDP 模态框宽度
rdpModalHeight?: string; // NEW: RDP 模态框高度
vncModalWidth?: string; // NEW: VNC 模态框宽度
vncModalHeight?: string; // NEW: VNC 模态框高度
ipBlacklistEnabled?: string;
dashboardSortBy?: SortField;
dashboardSortOrder?: SortOrder;
@@ -250,7 +252,14 @@ export const useSettingsStore = defineStore('settings', () => {
if (settings.value.rdpModalHeight === undefined) {
settings.value.rdpModalHeight = '858';
}
// NEW: VNC Modal Size defaults
if (settings.value.vncModalWidth === undefined) {
settings.value.vncModalWidth = '1024'; // 默认宽度
}
if (settings.value.vncModalHeight === undefined) {
settings.value.vncModalHeight = '768'; // 默认高度
}
if (settings.value.dashboardSortBy === undefined) {
settings.value.dashboardSortBy = 'last_connected_at';
}
@@ -364,6 +373,8 @@ export const useSettingsStore = defineStore('settings', () => {
'timezone', // NEW: 添加时区键
'rdpModalWidth', // NEW: 添加 RDP 模态框宽度键
'rdpModalHeight', // NEW: 添加 RDP 模态框高度键
'vncModalWidth', // NEW: 添加 VNC 模态框宽度键
'vncModalHeight', // NEW: 添加 VNC 模态框高度键
'ipBlacklistEnabled',
'dashboardSortBy',
'dashboardSortOrder',
@@ -451,6 +462,8 @@ export const useSettingsStore = defineStore('settings', () => {
'timezone', // NEW: 添加时区键
'rdpModalWidth', // NEW: 添加 RDP 模态框宽度键
'rdpModalHeight', // NEW: 添加 RDP 模态框高度键
'vncModalWidth', // NEW: 添加 VNC 模态框宽度键
'vncModalHeight', // NEW: 添加 VNC 模态框高度键
'ipBlacklistEnabled',
'dashboardSortBy',
'dashboardSortOrder',
@@ -320,7 +320,7 @@ const getTagNames = (tagIds: number[] | undefined): string[] => {
<li v-for="conn in filteredAndSortedConnections" :key="conn.id" class="flex items-center justify-between p-3 bg-header/50 border border-border/50 rounded transition duration-150 ease-in-out">
<div class="flex-grow mr-4 overflow-hidden">
<span class="font-medium block truncate flex items-center" :title="conn.name || ''">
<i :class="['fas', conn.type === 'RDP' ? 'fa-desktop' : 'fa-server', 'mr-2 w-4 text-center text-text-secondary']"></i>
<i :class="['fas', conn.type === 'VNC' ? 'fa-plug' : (conn.type === 'RDP' ? 'fa-desktop' : 'fa-server'), 'mr-2 w-4 text-center text-text-secondary']"></i>
<span>{{ conn.name || t('connections.unnamed') }}</span>
</span>
<span class="text-sm text-text-secondary block truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
+11 -14
View File
@@ -10,6 +10,7 @@ import TerminalTabBar from '../components/TerminalTabBar.vue';
import LayoutRenderer from '../components/LayoutRenderer.vue';
import LayoutConfigurator from '../components/LayoutConfigurator.vue';
import RemoteDesktopModal from '../components/RemoteDesktopModal.vue';
import VncModal from '../components/VncModal.vue'; // +++ VncModal +++
import Terminal from '../components/Terminal.vue'; // +++ Terminal +++
import CommandInputBar from '../components/CommandInputBar.vue'; // +++ CommandInputBar +++
import VirtualKeyboard from '../components/VirtualKeyboard.vue'; // +++ VirtualKeyboard +++
@@ -33,7 +34,7 @@ const breakpoints = useBreakpoints(breakpointsTailwind); // +++ 初始化 Breakp
const isMobile = breakpoints.smaller('md'); // +++ isMobile ( md ) +++
// --- Store Getters ---
const { sessionTabsWithStatus, activeSessionId, activeSession, isRdpModalOpen, rdpConnectionInfo } = storeToRefs(sessionStore); // 使 storeToRefs RDP
const { sessionTabsWithStatus, activeSessionId, activeSession, isRdpModalOpen, rdpConnectionInfo, isVncModalOpen, vncConnectionInfo } = storeToRefs(sessionStore); // 使 storeToRefs RDP VNC
const { shareFileEditorTabsBoolean, layoutLockedBoolean } = storeToRefs(settingsStore); // +++ Add layoutLockedBoolean +++
const { orderedTabs: globalEditorTabs, activeTabId: globalActiveEditorTabId } = storeToRefs(fileEditorStore);
const { layoutTree } = storeToRefs(layoutStore); //
@@ -448,14 +449,13 @@ const handleCloseEditorTab = (tabId: string) => {
// --- ( WorkspaceConnectionList) ---
const handleConnectRequest = (id: number) => {
console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`);
// +++ ConnectionInfo ID +++
const connectionInfo = connectionsStore.connections.find(c => c.id === id);
if (connectionInfo) {
sessionStore.handleConnectRequest(connectionInfo);
} else {
console.error(`[WorkspaceView] handleConnectRequest: 未找到 ID 为 ${id} 的连接信息。`);
}
const connectionInfo = connectionsStore.connections.find(c => c.id === id);
// console.log(`[WorkspaceView] Received 'connect-request' event for ID: ${id}`); //
if (connectionInfo) {
sessionStore.handleConnectRequest(connectionInfo);
} else {
console.error(`[WorkspaceView] handleConnectRequest: Connection info not found for ID ${id}.`); //
}
};
const handleOpenNewSession = (id: number) => {
console.log(`[WorkspaceView] Received 'open-new-session' event for ID: ${id}`);
@@ -650,11 +650,8 @@ const toggleVirtualKeyboard = () => {
@close="handleCloseLayoutConfigurator"
/>
<RemoteDesktopModal
v-if="isRdpModalOpen"
:connection="rdpConnectionInfo"
@close="sessionStore.closeRdpModal()"
/>
<!-- RDP Modal is now rendered in App.vue -->
<!-- VNC Modal is now rendered in App.vue -->
</div>
</template>
-1610
View File
File diff suppressed because it is too large Load Diff
-213
View File
@@ -1,213 +0,0 @@
// @ts-ignore - Still need this for the import as no types exist
import GuacamoleLite from 'guacamole-lite';
import express, { Request, Response } from 'express';
import http from 'http';
import crypto from 'crypto';
import cors from 'cors';
// --- 配置 ---
const GUAC_WS_PORT = process.env.GUAC_WS_PORT || 8081;
const API_PORT = process.env.API_PORT || 9090;
const GUACD_HOST = process.env.GUACD_HOST || 'localhost';
const GUACD_PORT = parseInt(process.env.GUACD_PORT || '4822', 10);
// --- 启动时生成内存加密密钥 ---
console.log("正在为此会话生成新的内存加密密钥...");
const ENCRYPTION_KEY_STRING = crypto.randomBytes(32).toString('hex');
const ENCRYPTION_KEY_BUFFER = Buffer.from(ENCRYPTION_KEY_STRING, 'hex');
console.log("内存加密密钥已生成。");
// --- Express 应用设置 ---
const app = express();
const apiServer = http.createServer(app);
const allowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:5173',
process.env.MAIN_BACKEND_URL || 'http://localhost:3000'
];
app.use(cors({ origin: allowedOrigins }));
const guacdOptions = {
host: GUACD_HOST,
port: GUACD_PORT,
};
const websocketOptions = {
port: GUAC_WS_PORT,
host: '0.0.0.0',
};
const clientOptions = {
crypt: {
// 将实际的密钥 Buffer 传递给 guacamole-lite 用于其内部加密操作
key: ENCRYPTION_KEY_BUFFER,
cypher: 'aes-256-cbc' // 确保加密和解密之间的密码算法一致
},
// 默认连接设置
connectionDefaultSettings: {
rdp: {
'security': 'nla',
'ignore-cert': 'true',
}
},
};
let guacServer: any;
try {
console.log(`[RDP 服务] 正在使用选项初始化 GuacamoleLite: WS 端口=${websocketOptions.port}, Guacd=${guacdOptions.host}:${guacdOptions.port}`);
guacServer = new GuacamoleLite(websocketOptions, guacdOptions, clientOptions);
console.log(`[RDP 服务] GuacamoleLite 初始化成功。`);
if (guacServer.on) {
guacServer.on('error', (error: Error) => {
console.error(`[RDP 服务] GuacamoleLite 服务器错误:`, error);
});
guacServer.on('connection', (client: any) => {
const clientId = client.id || '未知客户端ID';
console.log(`[RDP 服务] Guacd 连接事件触发。客户端 ID: ${clientId}`);
if (client && typeof client.on === 'function') {
client.on('disconnect', (reason: string) => {
console.log(`[RDP 服务] Guacd 连接断开。客户端 ID: ${clientId}, 原因: ${reason || '未知'}`);
});
client.on('error', (err: Error) => {
console.error(`[RDP 服务] Guacd 客户端错误。客户端 ID: ${clientId}, 错误:`, err);
});
client.on('message', (message: Buffer | string) => {
// 在回滚状态下移除了消息处理
});
} else {
// 对没有 'on' 方法的客户端进行最小化处理
}
});
}
} catch (error) {
console.error(`[RDP 服务] 初始化 GuacamoleLite 失败:`, error);
process.exit(1);
}
// 更新了 encryptToken 以匹配 guacamole-lite 期望的格式 (aes-256-cbc 和特定的 JSON 结构)
// 现在直接接受密钥 Buffer 以进行正确的加密操作
const encryptToken = (data: string, keyBuffer: Buffer): string => {
try {
const iv = crypto.randomBytes(16); // AES-CBC 通常使用 16 字节的 IV
// 使用密钥 Buffer 进行 Node.js 加密操作
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv);
let encrypted = cipher.update(data, 'utf8', 'base64');
encrypted += cipher.final('base64');
// 构建 guacamole-lite 的解密函数期望的 JSON 对象
const output = {
iv: iv.toString('base64'),
value: encrypted
};
// 将 JSON 字符串化,然后对整个字符串进行 Base64 编码
const jsonString = JSON.stringify(output);
return Buffer.from(jsonString).toString('base64');
} catch (e) {
console.error("令牌加密失败:", e);
throw new Error("令牌加密失败。");
}
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.get('/api/get-token', (req: any, res: any) => {
const { hostname, port, username, password, security = 'any', ignoreCert = 'true' } = req.query;
if (!hostname || !port || !username || typeof password === 'undefined') {
return res.status(400).json({ error: '缺少必需的 RDP 参数 (hostname, port, username, password)' });
}
const connectionParams = {
connection: {
type: 'rdp',
settings: {
hostname: hostname as string,
port: port as string,
username: username as string,
password: password as string,
// 从查询中包含动态(或默认)的大小参数
width: String(req.query.width || '1024'),
height: String(req.query.height || '768'),
dpi: String(req.query.dpi || '96'),
}
}
};
try {
const tokenData = JSON.stringify(connectionParams);
const encryptedToken = encryptToken(tokenData, ENCRYPTION_KEY_BUFFER);
res.json({ token: encryptedToken });
} catch (error) {
console.error("/api/get-token 接口出错:", error);
res.status(500).json({ error: '生成令牌失败' });
}
});
apiServer.listen(API_PORT, () => {
console.log(`[RDP 服务] API 服务器正在监听端口 ${API_PORT}`);
console.log(`[RDP 服务] Guacamole WebSocket 服务器应在端口 ${GUAC_WS_PORT} 上运行 (由 GuacamoleLite 管理)`);
});
const gracefulShutdown = (signal: string) => {
console.log(`收到 ${signal} 信号。正在优雅地关闭...`);
let guacClosed = false;
let apiClosed = false;
const tryExit = () => {
if (guacClosed && apiClosed) {
console.log("所有服务器已关闭。正在退出。");
process.exit(0);
}
};
apiServer.close((err) => {
if (err) {
console.error("关闭 API 服务器时出错:", err);
} else {
console.log("API 服务器已关闭。");
}
apiClosed = true;
tryExit();
});
// @ts-ignore - 假设基于通用模式存在 close 方法
if (typeof guacServer !== 'undefined' && guacServer && typeof guacServer.close === 'function') {
console.log("正在关闭 Guacamole 服务器..."); // 添加了关闭日志
// @ts-ignore
guacServer.close(() => {
console.log("Guacamole 服务器已关闭。"); // 添加了关闭日志
guacClosed = true;
tryExit();
});
} else {
console.log("Guacamole 服务器未运行或不支持 close() 方法。");
guacClosed = true;
tryExit();
}
// 超时后强制退出
setTimeout(() => {
console.error("关闭超时。强制退出。");
process.exit(1);
}, 10000); // 10 秒超时
};
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGUSR2', () => {
gracefulShutdown('SIGUSR2 (nodemon restart)');
});
@@ -5,14 +5,15 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Copy package.json and package-lock.json
COPY packages/rdp/package.json packages/rdp/package-lock.json* ./
COPY packages/remote-gateway/package.json packages/remote-gateway/package-lock.json* ./
# Install ALL dependencies (including devDependencies like typescript)
RUN npm install
# Copy source code and tsconfig
COPY packages/rdp/src ./src
COPY packages/rdp/tsconfig.json ./tsconfig.json
COPY packages/remote-gateway/src ./src
COPY packages/remote-gateway/tsconfig.json ./tsconfig.json
COPY packages/remote-gateway/guacamole-lite.d.ts ./guacamole-lite.d.ts
# Build the TypeScript code
RUN npm run build
@@ -28,7 +29,7 @@ WORKDIR /app
# Copy built code and node_modules from builder stage
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY packages/rdp/package.json ./package.json
COPY packages/remote-gateway/package.json ./package.json
# --- Add patch application steps ---
# Copy the patches directory from the build context (relative to project root)
@@ -47,6 +48,7 @@ RUN npm uninstall patch-package
# --- End patch application steps ---
# Expose the API and WebSocket ports
# These will be configurable via environment variables, but good to have defaults
EXPOSE 9090
EXPOSE 8081
@@ -1,5 +1,5 @@
{
"name": "@nexus-terminal/rdp",
"name": "@nexus-terminal/remote-gateway",
"version": "1.0.0",
"main": "index.js",
"scripts": {
@@ -11,7 +11,7 @@
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"description": "Unified Remote Desktop Gateway for Nexus Terminal",
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
@@ -27,4 +27,4 @@
"guacamole-lite": "^0.7.3",
"ws": "^8.18.1"
}
}
}
+220
View File
@@ -0,0 +1,220 @@
// @ts-ignore - Still need this for the import as no types exist
import GuacamoleLite from 'guacamole-lite';
import express, { Request, Response } from 'express';
import http from 'http';
import crypto from 'crypto';
import cors from 'cors';
// --- 配置 ---
const REMOTE_GATEWAY_WS_PORT = process.env.REMOTE_GATEWAY_WS_PORT || 8080; // 统一端口,或按需分开
const REMOTE_GATEWAY_API_PORT = process.env.REMOTE_GATEWAY_API_PORT || 9090;
const GUACD_HOST = process.env.GUACD_HOST || 'localhost';
const GUACD_PORT = parseInt(process.env.GUACD_PORT || '4822', 10);
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
const MAIN_BACKEND_URL = process.env.MAIN_BACKEND_URL || 'http://localhost:3000';
// --- 启动时生成内存加密密钥 ---
console.log("[Remote Gateway] 正在为此会话生成新的内存加密密钥...");
const ENCRYPTION_KEY_STRING = crypto.randomBytes(32).toString('hex');
const ENCRYPTION_KEY_BUFFER = Buffer.from(ENCRYPTION_KEY_STRING, 'hex');
console.log("[Remote Gateway] 内存加密密钥已生成。");
// --- Express 应用设置 ---
const app = express();
app.use(express.json()); // 用于解析请求体中的 JSON
const apiServer = http.createServer(app);
const allowedOrigins = [
FRONTEND_URL,
MAIN_BACKEND_URL
];
console.log(`[Remote Gateway] CORS 允许的来源: ${allowedOrigins.join(', ')}`);
app.use(cors({ origin: allowedOrigins }));
const guacdOptions = {
host: GUACD_HOST,
port: GUACD_PORT,
};
const websocketOptions = {
port: REMOTE_GATEWAY_WS_PORT,
host: '0.0.0.0', // 监听所有接口
};
const clientOptions = {
crypt: {
key: ENCRYPTION_KEY_BUFFER,
cypher: 'aes-256-cbc'
},
// 默认连接设置将根据协议动态调整
connectionDefaultSettings: {},
};
let guacServer: any;
try {
console.log(`[Remote Gateway] 正在使用选项初始化 GuacamoleLite: WS 端口=${websocketOptions.port}, Guacd=${guacdOptions.host}:${guacdOptions.port}`);
guacServer = new GuacamoleLite(websocketOptions, guacdOptions, clientOptions);
console.log(`[Remote Gateway] GuacamoleLite 初始化成功。`);
if (guacServer.on) {
guacServer.on('error', (error: Error) => {
console.error(`[Remote Gateway] GuacamoleLite 服务器错误:`, error);
});
guacServer.on('connection', (client: any) => {
const clientId = client.id || '未知客户端ID';
console.log(`[Remote Gateway] Guacd 连接事件触发。客户端 ID: ${clientId}`);
if (client && typeof client.on === 'function') {
client.on('disconnect', (reason: string) => {
console.log(`[Remote Gateway] Guacd 连接断开。客户端 ID: ${clientId}, 原因: ${reason || '未知'}`);
});
client.on('error', (err: Error) => {
console.error(`[Remote Gateway] Guacd 客户端错误。客户端 ID: ${clientId}, 错误:`, err);
});
}
});
}
} catch (error) {
console.error(`[Remote Gateway] 初始化 GuacamoleLite 失败:`, error);
process.exit(1);
}
const encryptToken = (data: string, keyBuffer: Buffer): string => {
try {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', keyBuffer, iv);
let encrypted = cipher.update(data, 'utf8', 'base64');
encrypted += cipher.final('base64');
const output = {
iv: iv.toString('base64'),
value: encrypted
};
const jsonString = JSON.stringify(output);
return Buffer.from(jsonString).toString('base64');
} catch (e) {
console.error("[Remote Gateway] 令牌加密失败:", e);
throw new Error("令牌加密失败。");
}
};
app.post('/api/remote-desktop/token', (req: Request, res: Response): void => {
const { protocol, connectionConfig } = req.body;
if (!protocol || !connectionConfig) {
res.status(400).json({ error: '缺少必需的参数 (protocol, connectionConfig)' });
return;
}
if (protocol !== 'rdp' && protocol !== 'vnc') {
res.status(400).json({ error: '无效的协议类型。支持 "rdp" 或 "vnc"。' });
return;
}
const { hostname, port, username, password, width, height, dpi, security, ignoreCert } = connectionConfig;
if (!hostname || !port) {
res.status(400).json({ error: '缺少必需的连接参数 (hostname, port)' });
return;
}
let settings: any = {
hostname: hostname as string,
port: port as string,
width: String(width || '1024'),
height: String(height || '768'),
};
if (protocol === 'rdp') {
if (typeof username === 'undefined' || typeof password === 'undefined') {
res.status(400).json({ error: 'RDP 连接缺少 username 或 password' });
return;
}
settings.username = username as string;
settings.password = password as string;
settings.security = security || 'any'; // RDP 特有,使用默认值 'any'
settings['ignore-cert'] = String(ignoreCert || 'true'); // RDP 特有
settings.dpi = String(dpi || '96'); // RDP 特有
} else if (protocol === 'vnc') {
if (typeof password === 'undefined') {
res.status(400).json({ error: 'VNC 连接缺少 password' });
return;
}
settings.password = password as string;
if (username) { // VNC 可选 username
settings.username = username as string;
}
// VNC 特有的其他参数可以根据需要从 connectionConfig 中获取并添加
// 例如: settings['enable-audio'] = connectionConfig.enableAudio || 'false';
}
const connectionParams = {
connection: {
type: protocol, // 'rdp' or 'vnc'
settings: settings
}
};
try {
const tokenData = JSON.stringify(connectionParams);
const encryptedToken = encryptToken(tokenData, ENCRYPTION_KEY_BUFFER);
res.json({ token: encryptedToken });
} catch (error) {
console.error("[Remote Gateway] /api/remote-desktop/token 接口出错:", error);
res.status(500).json({ error: '生成令牌失败' });
}
});
apiServer.listen(REMOTE_GATEWAY_API_PORT, () => {
console.log(`[Remote Gateway] API 服务器正在监听端口 ${REMOTE_GATEWAY_API_PORT}`);
console.log(`[Remote Gateway] Guacamole WebSocket 服务器应在端口 ${REMOTE_GATEWAY_WS_PORT} 上运行 (由 GuacamoleLite 管理)`);
});
const gracefulShutdown = (signal: string) => {
console.log(`[Remote Gateway] 收到 ${signal} 信号。正在优雅地关闭...`);
let guacClosed = false;
let apiClosed = false;
const tryExit = () => {
if (guacClosed && apiClosed) {
console.log("[Remote Gateway] 所有服务器已关闭。正在退出。");
process.exit(0);
}
};
apiServer.close((err) => {
if (err) {
console.error("[Remote Gateway] 关闭 API 服务器时出错:", err);
} else {
console.log("[Remote Gateway] API 服务器已关闭。");
}
apiClosed = true;
tryExit();
});
if (typeof guacServer !== 'undefined' && guacServer && typeof guacServer.close === 'function') {
console.log("[Remote Gateway] 正在关闭 Guacamole 服务器...");
guacServer.close(() => {
console.log("[Remote Gateway] Guacamole 服务器已关闭。");
guacClosed = true;
tryExit();
});
} else {
console.log("[Remote Gateway] Guacamole 服务器未运行或不支持 close() 方法。");
guacClosed = true;
tryExit();
}
setTimeout(() => {
console.error("[Remote Gateway] 关闭超时。强制退出。");
process.exit(1);
}, 10000); // 10 秒超时
};
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGUSR2', () => {
gracefulShutdown('SIGUSR2 (nodemon restart)');
});
@@ -114,4 +114,4 @@
"src/**/*", // Include all files in the src directory
"guacamole-lite.d.ts" // Include the specific .d.ts file
]
}
}