Initial commit

This commit is contained in:
2026-03-08 01:34:54 +08:00
commit 1f104f73c8
441 changed files with 64911 additions and 0 deletions

153
remote/src/config/index.js Normal file
View File

@@ -0,0 +1,153 @@
const path = require('path');
const fs = require('fs');
const { validate, getDefaults, mergeWithDefaults } = require('./schema');
const paths = require('../utils/paths');
let cachedConfig = null;
function getConfigPath() {
const configDir = paths.getConfigPath();
return path.join(configDir, 'default.json');
}
function loadConfigFile() {
const configPath = getConfigPath();
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(content);
return parsed;
}
} catch (error) {
console.error(`Failed to load config file: ${error.message}`);
}
return {};
}
function getEnvOverrides() {
const overrides = {};
const envPrefix = 'REMOTE_';
for (const [envKey, envValue] of Object.entries(process.env)) {
if (!envKey.startsWith(envPrefix)) continue;
const parts = envKey.slice(envPrefix.length).split('_');
if (parts.length < 2) continue;
const section = parts[0].toLowerCase();
const configKey = parts.slice(1).join('_').toLowerCase();
if (!overrides[section]) {
overrides[section] = {};
}
let parsedValue = envValue;
if (envValue === 'true') {
parsedValue = true;
} else if (envValue === 'false') {
parsedValue = false;
} else if (!isNaN(envValue) && envValue !== '') {
parsedValue = parseFloat(envValue);
if (Number.isInteger(parsedValue)) {
parsedValue = parseInt(envValue, 10);
}
}
overrides[section][configKey] = parsedValue;
}
return overrides;
}
function applyEnvOverrides(config, envOverrides) {
const result = { ...config };
for (const [section, values] of Object.entries(envOverrides)) {
if (!result[section]) {
result[section] = {};
}
for (const [key, value] of Object.entries(values)) {
result[section][key] = value;
}
}
return result;
}
function loadConfig() {
if (cachedConfig) {
return cachedConfig;
}
const defaults = getDefaults();
const fileConfig = loadConfigFile();
const envOverrides = getEnvOverrides();
let merged = mergeWithDefaults(fileConfig);
merged = applyEnvOverrides(merged, envOverrides);
const validation = validate(merged);
if (!validation.valid) {
console.warn('Config validation warnings:', validation.errors);
}
cachedConfig = merged;
return cachedConfig;
}
function get(key, defaultValue = undefined) {
const config = loadConfig();
if (!key) {
return config;
}
const keys = key.split('.');
let result = config;
for (const k of keys) {
if (result && typeof result === 'object' && k in result) {
result = result[k];
} else {
return defaultValue;
}
}
return result;
}
function getSection(section) {
const config = loadConfig();
if (!section) {
return config;
}
return config[section] || null;
}
function getAll() {
return loadConfig();
}
function reload() {
cachedConfig = null;
return loadConfig();
}
function clearCache() {
cachedConfig = null;
}
module.exports = {
get,
getSection,
getAll,
reload,
clearCache,
validate
};

201
remote/src/config/schema.js Normal file
View File

@@ -0,0 +1,201 @@
const defaultConfig = {
server: {
port: 3000,
host: '0.0.0.0'
},
stream: {
fps: 30,
bitrate: '4000k',
gop: 10,
preset: 'ultrafast',
resolution: {
width: 1920,
height: 1080
}
},
input: {
mouseEnabled: true,
keyboardEnabled: true,
sensitivity: 1.0
},
security: {
password: '',
tokenExpiry: 3600
},
frp: {
enabled: true
},
gitea: {
enabled: true
}
};
const schema = {
server: {
type: 'object',
properties: {
port: { type: 'number', required: true, min: 1, max: 65535 },
host: { type: 'string', required: true }
}
},
stream: {
type: 'object',
properties: {
fps: { type: 'number', required: true, min: 1, max: 120 },
bitrate: { type: 'string', required: true },
gop: { type: 'number', required: true, min: 1 },
preset: { type: 'string', required: true },
resolution: {
type: 'object',
properties: {
width: { type: 'number', required: true, min: 1 },
height: { type: 'number', required: true, min: 1 }
}
}
}
},
input: {
type: 'object',
properties: {
mouseEnabled: { type: 'boolean', required: true },
keyboardEnabled: { type: 'boolean', required: true },
sensitivity: { type: 'number', required: true, min: 0.1, max: 10 }
}
},
security: {
type: 'object',
properties: {
password: { type: 'string', required: false },
tokenExpiry: { type: 'number', required: true, min: 60 }
}
},
frp: {
type: 'object',
properties: {
enabled: { type: 'boolean', required: true }
}
},
gitea: {
type: 'object',
properties: {
enabled: { type: 'boolean', required: true }
}
}
};
function validateType(value, expectedType) {
if (expectedType === 'number') {
return typeof value === 'number' && !isNaN(value);
}
if (expectedType === 'string') {
return typeof value === 'string';
}
if (expectedType === 'boolean') {
return typeof value === 'boolean';
}
if (expectedType === 'object') {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
return false;
}
function validateField(value, fieldSchema, fieldPath) {
const errors = [];
if (!validateType(value, fieldSchema.type)) {
errors.push(`${fieldPath}: expected type ${fieldSchema.type}, got ${typeof value}`);
return errors;
}
if (fieldSchema.type === 'number') {
if (fieldSchema.min !== undefined && value < fieldSchema.min) {
errors.push(`${fieldPath}: value ${value} is less than minimum ${fieldSchema.min}`);
}
if (fieldSchema.max !== undefined && value > fieldSchema.max) {
errors.push(`${fieldPath}: value ${value} is greater than maximum ${fieldSchema.max}`);
}
}
if (fieldSchema.type === 'object' && fieldSchema.properties) {
const nestedErrors = validateObject(value, fieldSchema, fieldPath);
errors.push(...nestedErrors);
}
return errors;
}
function validateObject(obj, objectSchema, basePath) {
const errors = [];
if (!objectSchema.properties) return errors;
for (const [key, fieldSchema] of Object.entries(objectSchema.properties)) {
const fieldPath = basePath ? `${basePath}.${key}` : key;
if (!(key in obj)) {
if (fieldSchema.required) {
errors.push(`${fieldPath}: required field is missing`);
}
continue;
}
const fieldErrors = validateField(obj[key], fieldSchema, fieldPath);
errors.push(...fieldErrors);
}
return errors;
}
function validate(config) {
const errors = [];
if (!config || typeof config !== 'object') {
return { valid: false, errors: ['Config must be a non-null object'] };
}
for (const [sectionKey, sectionSchema] of Object.entries(schema)) {
if (!(sectionKey in config)) {
errors.push(`${sectionKey}: required section is missing`);
continue;
}
const sectionErrors = validateObject(config[sectionKey], sectionSchema, sectionKey);
errors.push(...sectionErrors);
}
return {
valid: errors.length === 0,
errors
};
}
function getDefaults() {
return JSON.parse(JSON.stringify(defaultConfig));
}
function mergeWithDefaults(config) {
const defaults = getDefaults();
return deepMerge(defaults, config);
}
function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
result[key] = deepMerge(target[key], source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
module.exports = {
schema,
defaultConfig,
validate,
getDefaults,
mergeWithDefaults
};

View File

@@ -0,0 +1,97 @@
const AuthService = require('../services/auth/AuthService');
const TokenManager = require('../services/auth/TokenManager');
const logger = require('../utils/logger');
class AuthController {
constructor() {
this.authService = AuthService.getInstance();
this.tokenManager = TokenManager.getInstance();
}
async login(req, res) {
const { password } = req.body;
if (!this.authService.hasPassword()) {
const token = this.tokenManager.generateToken({ userId: 'default-user' });
logger.info('Login successful: no password configured');
return res.json({
success: true,
token,
message: 'Authentication disabled'
});
}
if (!password) {
logger.warn('Login failed: no password provided');
return res.status(400).json({
success: false,
error: 'Password is required'
});
}
try {
const isValid = await this.authService.authenticate(password);
if (isValid) {
const token = this.tokenManager.generateToken({ userId: 'default-user' });
logger.info('Login successful');
return res.json({
success: true,
token
});
} else {
logger.warn('Login failed: invalid password');
return res.status(401).json({
success: false,
error: 'Invalid password'
});
}
} catch (error) {
logger.error('Login error', { error: error.message });
return res.status(500).json({
success: false,
error: 'Authentication failed'
});
}
}
async verify(req, res) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({
success: false,
valid: false,
error: 'No token provided'
});
}
try {
const decoded = this.tokenManager.verifyToken(token);
if (decoded) {
return res.json({
success: true,
valid: true,
userId: decoded.userId
});
} else {
return res.status(401).json({
success: false,
valid: false,
error: 'Invalid or expired token'
});
}
} catch (error) {
logger.error('Token verification error', { error: error.message });
return res.status(500).json({
success: false,
valid: false,
error: 'Token verification failed'
});
}
}
}
module.exports = AuthController;

View File

@@ -0,0 +1,86 @@
const { PowerShellInput } = require('../services/input');
const logger = require('../utils/logger');
class InputController {
constructor() {
this.inputService = new PowerShellInput();
}
async mouseMove(x, y) {
try {
await this.inputService.mouseMove(x, y);
} catch (error) {
logger.error('Mouse move failed', { error: error.message, x, y });
}
}
async mouseDown(button = 'left') {
try {
await this.inputService.mouseDown(button);
} catch (error) {
logger.error('Mouse down failed', { error: error.message, button });
}
}
async mouseUp(button = 'left') {
try {
await this.inputService.mouseUp(button);
} catch (error) {
logger.error('Mouse up failed', { error: error.message, button });
}
}
async mouseClick(button = 'left') {
try {
await this.inputService.mouseClick(button);
} catch (error) {
logger.error('Mouse click failed', { error: error.message, button });
}
}
async mouseWheel(delta) {
try {
await this.inputService.mouseWheel(delta);
} catch (error) {
logger.error('Mouse wheel failed', { error: error.message, delta });
}
}
async keyDown(key) {
try {
await this.inputService.keyDown(key);
} catch (error) {
logger.error('Key down failed', { error: error.message, key });
}
}
async keyUp(key) {
try {
await this.inputService.keyUp(key);
} catch (error) {
logger.error('Key up failed', { error: error.message, key });
}
}
async keyPress(key) {
try {
await this.inputService.keyPress(key);
} catch (error) {
logger.error('Key press failed', { error: error.message, key });
}
}
async keyType(text) {
try {
await this.inputService.keyType(text);
} catch (error) {
logger.error('Key type failed', { error: error.message, text });
}
}
stop() {
this.inputService.stop();
}
}
module.exports = InputController;

View File

@@ -0,0 +1,102 @@
const FFmpegEncoder = require('../services/stream/FFmpegEncoder');
const logger = require('../utils/logger');
class StreamController {
constructor() {
this.encoder = null;
}
_getEncoder() {
if (!this.encoder) {
this.encoder = new FFmpegEncoder();
}
return this.encoder;
}
getInfo(req, res) {
try {
const encoder = this._getEncoder();
const resolution = encoder.getScreenResolution();
const isRunning = encoder.isRunning();
const info = {
success: true,
stream: {
status: isRunning ? 'running' : 'stopped',
resolution: {
width: resolution.width,
height: resolution.height
},
fps: encoder.fps,
bitrate: encoder.bitrate,
gop: encoder.gop,
encoder: encoder.getEncoder()
}
};
return res.json(info);
} catch (error) {
logger.error('Failed to get stream info', { error: error.message });
return res.status(500).json({
success: false,
error: 'Failed to get stream info'
});
}
}
start(req, res) {
try {
const encoder = this._getEncoder();
if (encoder.isRunning()) {
return res.json({
success: true,
message: 'Stream is already running'
});
}
encoder.start();
logger.info('Stream started');
return res.json({
success: true,
message: 'Stream started'
});
} catch (error) {
logger.error('Failed to start stream', { error: error.message });
return res.status(500).json({
success: false,
error: 'Failed to start stream'
});
}
}
stop(req, res) {
try {
const encoder = this._getEncoder();
if (!encoder.isRunning()) {
return res.json({
success: true,
message: 'Stream is not running'
});
}
encoder.stop();
logger.info('Stream stopped');
return res.json({
success: true,
message: 'Stream stopped'
});
} catch (error) {
logger.error('Failed to stop stream', { error: error.message });
return res.status(500).json({
success: false,
error: 'Failed to stop stream'
});
}
}
}
module.exports = StreamController;

454
remote/src/core/App.js Normal file
View File

@@ -0,0 +1,454 @@
const Container = require('./Container');
const EventBus = require('./EventBus');
const EventTypes = require('./events');
const { ErrorHandler } = require('./ErrorHandler');
const logger = require('../utils/logger');
const MessageTypes = require('../server/messageTypes');
const SHUTDOWN_TIMEOUT = 30000;
class App {
constructor() {
this.container = null;
this.eventBus = null;
this.started = false;
this.shuttingDown = false;
this.lastClipboardText = '';
}
async bootstrap() {
this.container = new Container();
this.eventBus = new EventBus();
this.container.register('eventBus', () => this.eventBus);
this._registerConfig();
this._registerLogger();
this._registerErrorHandler();
this._registerAuthServices();
this._registerStreamServices();
this._registerInputServices();
this._registerClipboardServices();
this._registerFileServices();
this._registerNetworkServices();
this._registerServer();
logger.info('Application bootstrap completed');
return this;
}
_registerConfig() {
this.container.register('config', (c) => {
return require('../config');
});
}
_registerLogger() {
this.container.register('logger', (c) => {
return require('../utils/logger');
});
}
_registerEventBus() {
this.container.register('eventBus', (c) => {
return this.eventBus;
});
}
_registerErrorHandler() {
this.container.register('errorHandler', (c) => {
return new ErrorHandler();
});
}
_registerAuthServices() {
this.container.register('authService', (c) => {
const AuthService = require('../services/auth/AuthService');
return new AuthService();
});
this.container.register('tokenManager', (c) => {
const TokenManager = require('../services/auth/TokenManager');
return new TokenManager();
});
}
_registerStreamServices() {
this.container.register('ffmpegEncoder', (c) => {
const FFmpegEncoder = require('../services/stream/FFmpegEncoder');
return new FFmpegEncoder();
});
}
_registerInputServices() {
this.container.register('inputService', (c) => {
const PowerShellInput = require('../services/input/PowerShellInput');
return new PowerShellInput();
});
this.container.register('inputHandler', (c) => {
const InputHandler = require('../server/InputHandler');
const inputService = c.resolve('inputService');
return new InputHandler(inputService);
});
}
_registerClipboardServices() {
this.container.register('clipboardService', (c) => {
const { clipboardService } = require('../services/clipboard');
return clipboardService;
});
}
_registerFileServices() {
this.container.register('fileService', (c) => {
const { fileService } = require('../services/file');
return fileService;
});
}
_registerNetworkServices() {
this.container.register('frpService', (c) => {
const FRPService = require('../services/network/FRPService');
const config = c.resolve('config');
const frpConfig = config.getSection('frp') || {};
return new FRPService({
enabled: frpConfig.enabled !== false
});
});
this.container.register('giteaService', (c) => {
const GiteaService = require('../services/network/GiteaService');
const config = c.resolve('config');
const giteaConfig = config.getSection('gitea') || {};
return new GiteaService({
enabled: giteaConfig.enabled !== false
});
});
}
_registerServer() {
this.container.register('httpServer', (c) => {
const Server = require('../server/Server');
const config = c.resolve('config');
const serverConfig = config.getSection('server') || {};
return new Server({
port: serverConfig.port || 3000,
host: serverConfig.host || '0.0.0.0'
});
});
this.container.register('wsServer', (c) => {
const WebSocketServer = require('../server/WebSocketServer');
return new WebSocketServer();
});
this.container.register('streamBroadcaster', (c) => {
const StreamBroadcaster = require('../server/StreamBroadcaster');
const wsServer = c.resolve('wsServer');
return new StreamBroadcaster(wsServer);
});
}
async start() {
if (this.started) {
logger.warn('Application already started');
return;
}
const errorHandler = this.container.resolve('errorHandler');
errorHandler.initialize();
this._setupRoutes();
const httpServer = this.container.resolve('httpServer');
const address = await httpServer.start();
logger.info('HTTP server started', { address });
const wsServer = this.container.resolve('wsServer');
wsServer.start(httpServer.getHTTPServer());
logger.info('WebSocket server started');
this._setupWebSocketHandlers();
const ffmpegEncoder = this.container.resolve('ffmpegEncoder');
ffmpegEncoder.start();
logger.info('FFmpeg encoder started');
const screenRes = ffmpegEncoder.getScreenResolution();
wsServer.setScreenResolution(screenRes.width, screenRes.height);
const streamBroadcaster = this.container.resolve('streamBroadcaster');
streamBroadcaster.setEncoder(ffmpegEncoder);
logger.info('Stream broadcaster attached to encoder');
const inputService = this.container.resolve('inputService');
await inputService.start();
logger.info('Input service started');
const frpService = this.container.resolve('frpService');
frpService.start();
logger.info('FRP service started');
const giteaService = this.container.resolve('giteaService');
giteaService.start();
logger.info('Gitea service started');
// 启动剪贴板监控,主动同步到主控
this._startClipboardWatcher();
this.started = true;
await this.eventBus.emit(EventTypes.APP_START, {
timestamp: new Date().toISOString(),
address
});
logger.info('Application started successfully');
this._setupGracefulShutdown();
}
_setupRoutes() {
const httpServer = this.container.resolve('httpServer');
const config = this.container.resolve('config');
const authService = this.container.resolve('authService');
const tokenManager = this.container.resolve('tokenManager');
const inputHandler = this.container.resolve('inputHandler');
const paths = require('../utils/paths');
const express = require('express');
const cookieParser = require('cookie-parser');
const authMiddleware = require('../middlewares/auth');
const routes = require('../routes');
httpServer.use(cookieParser());
httpServer.use(express.json());
httpServer.use(express.urlencoded({ extended: true }));
httpServer.app.post('/login', async (req, res) => {
const { password } = req.body;
const isValid = await authService.authenticate(password);
if (isValid) {
const token = tokenManager.generateToken({ userId: 'default-user' });
res.cookie('auth', token, { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
return res.redirect('/');
}
httpServer.renderLoginPage(res, '密码错误');
});
httpServer.use((req, res, next) => {
if (!authService.hasPassword()) {
res.locals.authenticated = true;
return next();
}
const token = req.cookies && req.cookies.auth;
if (token) {
const decoded = tokenManager.verifyToken(token);
if (decoded) {
res.locals.authenticated = true;
return next();
}
}
if (req.path === '/' || req.path === '/index.html') {
return httpServer.renderLoginPage(res);
}
res.status(401).json({ error: 'Authentication required' });
});
httpServer.static(paths.getPublicPath());
httpServer.use('/api', authMiddleware);
httpServer.use('/api', routes);
httpServer.app.get('/api/config', (req, res) => {
try {
res.json(config.getAll());
} catch (error) {
logger.error('Error getting config', { error: error.message });
res.status(500).json({ error: 'Failed to get config' });
}
});
const wsServer = this.container.resolve('wsServer');
const originalSetup = wsServer.setupConnectionHandler.bind(wsServer);
wsServer.setupConnectionHandler = function() {
originalSetup();
const originalHandlers = this.wss.listeners('connection');
this.wss.removeAllListeners('connection');
const securityConfig = require('../utils/config').getSecurityConfig();
const password = securityConfig.password;
// 未认证的连接也允许,用于剪贴板同步
this.wss.on('connection', (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const isAuthenticated = url.searchParams.get('password') === password;
// 保存认证状态
ws.isAuthenticated = isAuthenticated;
// 处理输入消息(不检查认证)
ws.on('message', (data) => {
try {
const message = JSON.parse(data);
inputHandler.handleMessage(message, ws);
} catch (error) {
logger.debug('Failed to parse WebSocket message', { error: error.message });
}
});
// 调用原始 handlers用于已认证的连接
originalHandlers.forEach(handler => {
handler(ws, req);
});
});
};
}
_setupWebSocketHandlers() {
// WebSocket handlers set up in _ aresetupRoutes()
// to avoid duplicate message handling
}
_startClipboardWatcher() {
const clipboardService = this.container.resolve('clipboardService');
const wsServer = this.container.resolve('wsServer');
clipboardService.read().then(content => {
if (content.type === 'text') {
this.lastClipboardText = content.data || '';
}
}).catch(() => {});
setInterval(async () => {
try {
const content = await clipboardService.read();
if (content.type === 'text' && content.data !== this.lastClipboardText) {
this.lastClipboardText = content.data || '';
const message = JSON.stringify({
type: MessageTypes.CLIPBOARD_DATA,
contentType: content.type,
data: content.data,
size: content.size
});
wsServer.clients.forEach((client) => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(message);
}
});
logger.info('Clipboard changed, synced to client');
}
} catch (error) {
// ignore
}
}, 1000);
}
_setupGracefulShutdown() {
const shutdown = async (signal) => {
if (this.shuttingDown) {
return;
}
this.shuttingDown = true;
logger.info(`Received ${signal}, starting graceful shutdown`);
await this.stop();
process.exit(0);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('exit', () => {
logger.info('Process exiting');
});
}
async stop() {
if (!this.started) {
return;
}
logger.info('Stopping application');
const shutdownPromise = async () => {
const frpService = this.container.resolve('frpService');
frpService.stop();
logger.info('FRP service stopped');
const giteaService = this.container.resolve('giteaService');
giteaService.stop();
logger.info('Gitea service stopped');
const streamBroadcaster = this.container.resolve('streamBroadcaster');
streamBroadcaster.stop();
logger.info('Stream broadcaster stopped');
const ffmpegEncoder = this.container.resolve('ffmpegEncoder');
ffmpegEncoder.stop();
logger.info('FFmpeg encoder stopped');
const inputHandler = this.container.resolve('inputHandler');
inputHandler.stop();
logger.info('Input handler stopped');
const inputService = this.container.resolve('inputService');
await inputService.stop();
logger.info('Input service stopped');
const wsServer = this.container.resolve('wsServer');
wsServer.stop();
logger.info('WebSocket server stopped');
const httpServer = this.container.resolve('httpServer');
await httpServer.stop();
logger.info('HTTP server stopped');
await this.eventBus.emit(EventTypes.APP_STOP, {
timestamp: new Date().toISOString()
});
this.started = false;
logger.info('Application stopped successfully');
};
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('Shutdown timeout exceeded'));
}, SHUTDOWN_TIMEOUT);
});
try {
await Promise.race([shutdownPromise(), timeoutPromise]);
} catch (error) {
logger.error('Error during shutdown', { error: error.message });
this.started = false;
}
}
getService(name) {
if (!this.container) {
throw new Error('Application not bootstrapped');
}
return this.container.resolve(name);
}
getEventBus() {
return this.eventBus;
}
isStarted() {
return this.started;
}
}
module.exports = App;

View File

@@ -0,0 +1,77 @@
class Container {
constructor() {
this._services = new Map();
this._singletons = new Map();
this._resolving = new Set();
}
register(name, factory, isSingleton = true) {
if (typeof name !== 'string' || name.trim() === '') {
throw new Error('Service name must be a non-empty string');
}
if (typeof factory !== 'function') {
throw new Error('Factory must be a function');
}
if (this._services.has(name)) {
throw new Error(`Service "${name}" is already registered`);
}
this._services.set(name, { factory, isSingleton });
return this;
}
resolve(name) {
if (!this._services.has(name)) {
throw new Error(`Service "${name}" is not registered`);
}
if (this._resolving.has(name)) {
const chain = Array.from(this._resolving).join(' -> ');
throw new Error(
`Circular dependency detected: ${chain} -> ${name}. ` +
`This creates a circular reference that cannot be resolved.`
);
}
const service = this._services.get(name);
if (service.isSingleton && this._singletons.has(name)) {
return this._singletons.get(name);
}
this._resolving.add(name);
try {
const instance = service.factory(this);
if (service.isSingleton) {
this._singletons.set(name, instance);
}
return instance;
} finally {
this._resolving.delete(name);
}
}
has(name) {
return this._services.has(name);
}
unregister(name) {
if (!this._services.has(name)) {
return false;
}
this._services.delete(name);
this._singletons.delete(name);
return true;
}
clear() {
this._services.clear();
this._singletons.clear();
this._resolving.clear();
}
}
module.exports = Container;

View File

@@ -0,0 +1,81 @@
const logger = require('../utils/logger');
function createErrorResponse(error, code, details) {
const response = {
error: error instanceof Error ? error.message : String(error)
};
if (code) {
response.code = code;
}
if (details !== undefined) {
response.details = details;
}
return response;
}
class ErrorHandler {
constructor() {
this.initialized = false;
}
initialize() {
if (this.initialized) {
return;
}
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception:', {
message: error.message,
stack: error.stack,
name: error.name
});
setTimeout(() => {
process.exit(1);
}, 1000);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection:', {
reason: reason instanceof Error
? { message: reason.message, stack: reason.stack, name: reason.name }
: reason,
promise: String(promise)
});
});
this.initialized = true;
logger.info('Global error handlers initialized');
}
handleError(error, context = {}) {
const errorInfo = {
message: error.message,
stack: error.stack,
name: error.name,
context
};
logger.error('Error occurred:', errorInfo);
return createErrorResponse(error);
}
createError(message, code, details) {
const error = new Error(message);
error.code = code;
error.details = details;
return error;
}
}
const errorHandler = new ErrorHandler();
module.exports = {
ErrorHandler,
errorHandler,
createErrorResponse
};

100
remote/src/core/EventBus.js Normal file
View File

@@ -0,0 +1,100 @@
class EventBus {
constructor() {
this._listeners = new Map();
}
on(event, handler) {
if (typeof event !== 'string' || event.trim() === '') {
throw new Error('Event name must be a non-empty string');
}
if (typeof handler !== 'function') {
throw new Error('Handler must be a function');
}
if (!this._listeners.has(event)) {
this._listeners.set(event, []);
}
this._listeners.get(event).push(handler);
return this;
}
off(event, handler) {
if (!this._listeners.has(event)) {
return false;
}
const handlers = this._listeners.get(event);
const index = handlers.indexOf(handler);
if (index === -1) {
return false;
}
handlers.splice(index, 1);
if (handlers.length === 0) {
this._listeners.delete(event);
}
return true;
}
async emit(event, data) {
if (!this._listeners.has(event)) {
return [];
}
const handlers = this._listeners.get(event);
const results = [];
for (const handler of handlers) {
try {
const result = await handler(data);
results.push(result);
} catch (error) {
results.push({ error });
}
}
return results;
}
once(event, handler) {
if (typeof event !== 'string' || event.trim() === '') {
throw new Error('Event name must be a non-empty string');
}
if (typeof handler !== 'function') {
throw new Error('Handler must be a function');
}
const onceHandler = async (data) => {
this.off(event, onceHandler);
return await handler(data);
};
return this.on(event, onceHandler);
}
removeAllListeners(event) {
if (event === undefined) {
this._listeners.clear();
return true;
}
if (typeof event !== 'string') {
throw new Error('Event name must be a string');
}
return this._listeners.delete(event);
}
listenerCount(event) {
if (!this._listeners.has(event)) {
return 0;
}
return this._listeners.get(event).length;
}
}
module.exports = EventBus;

27
remote/src/core/events.js Normal file
View File

@@ -0,0 +1,27 @@
const EventTypes = {
STREAM_START: 'stream:start',
STREAM_STOP: 'stream:stop',
STREAM_DATA: 'stream:data',
STREAM_ERROR: 'stream:error',
CLIENT_CONNECTED: 'client:connected',
CLIENT_DISCONNECTED: 'client:disconnected',
INPUT_EVENT: 'input:event',
APP_START: 'app:start',
APP_STOP: 'app:stop',
ERROR: 'error',
// Agent events
AGENT_CONNECTED: 'agent:connected',
AGENT_DISCONNECTED: 'agent:disconnected',
AGENT_REGISTERED: 'agent:registered',
AGENT_HEARTBEAT: 'agent:heartbeat',
AGENT_STREAM_START: 'agent:stream:start',
AGENT_STREAM_STOP: 'agent:stream:stop',
// Controller events
CONTROLLER_CONNECTED: 'controller:connected',
CONTROLLER_DISCONNECTED: 'controller:disconnected',
CONTROLLER_STREAM_SWITCH: 'controller:stream:switch'
};
module.exports = EventTypes;

37
remote/src/index.js Normal file
View File

@@ -0,0 +1,37 @@
const App = require('./core/App');
const { ErrorHandler } = require('./core/ErrorHandler');
const logger = require('./utils/logger');
const errorHandler = new ErrorHandler();
errorHandler.initialize();
const app = new App();
async function main() {
try {
await app.bootstrap();
await app.start();
} catch (error) {
logger.error('Failed to start application', { error: error.message, stack: error.stack });
process.exit(1);
}
}
async function gracefulShutdown(signal) {
if (app.isStarted()) {
logger.info(`Received ${signal}, shutting down gracefully`);
await app.stop();
}
process.exit(0);
}
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('exit', () => {
logger.info('Process exiting');
});
main();
module.exports = app;

View File

@@ -0,0 +1,80 @@
const AuthService = require('../services/auth/AuthService');
const TokenManager = require('../services/auth/TokenManager');
const logger = require('../utils/logger');
function extractToken(req) {
if (req.headers.authorization) {
const parts = req.headers.authorization.split(' ');
if (parts.length === 2 && parts[0] === 'Bearer') {
return parts[1];
}
}
if (req.cookies) {
if (req.cookies.token) {
return req.cookies.token;
}
if (req.cookies.auth) {
return req.cookies.auth;
}
}
if (req.query && req.query.token) {
return req.query.token;
}
return null;
}
async function authMiddleware(req, res, next) {
const authService = AuthService.getInstance();
const tokenManager = TokenManager.getInstance();
if (!authService.hasPassword()) {
req.user = { userId: 'default-user' };
res.locals.authenticated = true;
return next();
}
const token = extractToken(req);
if (token) {
const decoded = tokenManager.verifyToken(token);
if (decoded) {
req.user = { userId: decoded.userId };
res.locals.authenticated = true;
logger.debug('Authentication successful via token', { userId: decoded.userId });
return next();
}
}
const password = req.query.password || req.body?.password;
if (password) {
try {
const isValid = await authService.authenticate(password);
if (isValid) {
req.user = { userId: 'default-user' };
res.locals.authenticated = true;
logger.debug('Authentication successful via password');
return next();
}
} catch (error) {
logger.error('Authentication error', { error: error.message });
}
}
logger.warn('Authentication failed', {
ip: req.socket?.remoteAddress,
path: req.path,
hasToken: !!token,
hasPassword: !!password
});
res.status(401).json({
error: 'Authentication required',
code: 'AUTH_REQUIRED'
});
}
module.exports = authMiddleware;

View File

@@ -0,0 +1,78 @@
const logger = require('../utils/logger');
function errorHandler(err, req, res, next) {
const statusCode = err.statusCode || err.status || 500;
const errorCode = err.code || 'INTERNAL_ERROR';
const errorResponse = {
error: err.message || 'Internal Server Error',
code: errorCode
};
if (err.details) {
errorResponse.details = err.details;
}
if (statusCode >= 500) {
logger.error('Server error', {
error: err.message,
code: errorCode,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.socket?.remoteAddress
});
} else {
logger.warn('Client error', {
error: err.message,
code: errorCode,
path: req.path,
method: req.method,
ip: req.socket?.remoteAddress
});
}
if (process.env.NODE_ENV === 'development' && err.stack) {
errorResponse.stack = err.stack;
}
res.status(statusCode).json(errorResponse);
}
class AppError extends Error {
constructor(message, statusCode = 500, code = 'APP_ERROR') {
super(message);
this.statusCode = statusCode;
this.code = code;
this.status = statusCode;
Error.captureStackTrace(this, this.constructor);
}
withDetails(details) {
this.details = details;
return this;
}
}
function createError(message, statusCode = 500, code = 'APP_ERROR') {
return new AppError(message, statusCode, code);
}
function notFoundHandler(req, res, next) {
const error = new AppError(`Not Found - ${req.originalUrl}`, 404, 'NOT_FOUND');
next(error);
}
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
module.exports = {
errorHandler,
AppError,
createError,
notFoundHandler,
asyncHandler
};

View File

@@ -0,0 +1,87 @@
const logger = require('../utils/logger');
class RateLimiter {
constructor(options = {}) {
this.windowMs = options.windowMs || 60 * 1000;
this.maxRequests = options.maxRequests || 5;
this.requests = new Map();
this.cleanupInterval = setInterval(() => this.cleanup(), this.windowMs);
}
cleanup() {
const now = Date.now();
for (const [key, data] of this.requests.entries()) {
if (now - data.startTime > this.windowMs) {
this.requests.delete(key);
}
}
}
getKey(req) {
return req.ip || req.socket?.remoteAddress || 'unknown';
}
middleware() {
return (req, res, next) => {
const key = this.getKey(req);
const now = Date.now();
let requestData = this.requests.get(key);
if (!requestData || now - requestData.startTime > this.windowMs) {
requestData = {
count: 0,
startTime: now
};
this.requests.set(key, requestData);
}
requestData.count++;
const remainingTime = Math.ceil((requestData.startTime + this.windowMs - now) / 1000);
res.setHeader('X-RateLimit-Limit', this.maxRequests);
res.setHeader('X-RateLimit-Remaining', Math.max(0, this.maxRequests - requestData.count));
res.setHeader('X-RateLimit-Reset', remainingTime);
if (requestData.count > this.maxRequests) {
logger.warn('Rate limit exceeded', {
ip: key,
path: req.path,
count: requestData.count,
maxRequests: this.maxRequests
});
return res.status(429).json({
error: 'Too many requests, please try again later',
code: 'RATE_LIMIT_EXCEEDED',
retryAfter: remainingTime
});
}
next();
};
}
stop() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
function createRateLimiter(options = {}) {
const limiter = new RateLimiter(options);
return limiter.middleware();
}
const defaultRateLimiter = createRateLimiter({
windowMs: 60 * 1000,
maxRequests: 5
});
module.exports = {
RateLimiter,
createRateLimiter,
defaultRateLimiter
};

15
remote/src/routes/auth.js Normal file
View File

@@ -0,0 +1,15 @@
const express = require('express');
const AuthController = require('../controllers/AuthController');
const router = express.Router();
const authController = new AuthController();
router.post('/login', (req, res) => {
authController.login(req, res);
});
router.post('/verify', (req, res) => {
authController.verify(req, res);
});
module.exports = router;

135
remote/src/routes/files.js Normal file
View File

@@ -0,0 +1,135 @@
const express = require('express');
const multer = require('multer');
const crypto = require('crypto');
const { fileService } = require('../services/file');
const logger = require('../utils/logger');
const router = express.Router();
const upload = multer({ storage: multer.memoryStorage() });
router.get('/', (req, res) => {
try {
const files = fileService.getFileList();
res.json({ files });
} catch (error) {
logger.error('Failed to get file list', { error: error.message });
res.status(500).json({ error: 'Failed to get file list' });
}
});
router.get('/browse', (req, res) => {
try {
const path = req.query.path || '';
const result = fileService.browseDirectory(path);
res.json(result);
} catch (error) {
logger.error('Failed to browse directory', { error: error.message });
res.status(500).json({ error: 'Failed to browse directory' });
}
});
router.get('/:filename', (req, res) => {
try {
const filename = req.params.filename;
const range = req.headers.range;
const result = fileService.getFileStream(filename, range);
if (!result) {
return res.status(404).json({ error: 'File not found' });
}
const headers = {
'Content-Type': 'application/octet-stream',
'Accept-Ranges': 'bytes'
};
if (range && result.contentRange) {
headers['Content-Range'] = result.contentRange;
headers['Content-Length'] = result.contentLength;
res.writeHead(206, headers);
} else {
headers['Content-Length'] = result.contentLength;
res.writeHead(200, headers);
}
result.stream.pipe(res);
} catch (error) {
logger.error('Failed to download file', { error: error.message });
res.status(500).json({ error: 'Failed to download file' });
}
});
router.post('/upload/start', (req, res) => {
try {
const { filename, totalChunks, fileSize } = req.body;
const fileId = crypto.randomBytes(16).toString('hex');
res.json({
fileId,
chunkSize: 5 * 1024 * 1024,
message: 'Upload session started'
});
} catch (error) {
logger.error('Failed to start upload', { error: error.message });
res.status(500).json({ error: 'Failed to start upload' });
}
});
router.post('/upload/chunk', upload.single('chunk'), (req, res) => {
try {
const { fileId, chunkIndex } = req.body;
if (!req.file) {
return res.status(400).json({ error: 'No chunk data provided' });
}
const success = fileService.saveChunk(fileId, parseInt(chunkIndex), req.file.buffer);
if (success) {
res.json({ success: true, chunkIndex: parseInt(chunkIndex) });
} else {
res.status(500).json({ error: 'Failed to save chunk' });
}
} catch (error) {
logger.error('Failed to upload chunk', { error: error.message });
res.status(500).json({ error: 'Failed to upload chunk' });
}
});
router.post('/upload/merge', (req, res) => {
try {
const { fileId, totalChunks, filename } = req.body;
if (!fileId || !totalChunks || !filename) {
return res.status(400).json({ error: 'Missing required fields' });
}
const success = fileService.mergeChunks(fileId, parseInt(totalChunks), filename);
if (success) {
res.json({ success: true, filename });
} else {
fileService.cleanupChunks(fileId);
res.status(500).json({ error: 'Failed to merge chunks' });
}
} catch (error) {
logger.error('Failed to merge chunks', { error: error.message });
res.status(500).json({ error: 'Failed to merge chunks' });
}
});
router.delete('/:filename', (req, res) => {
try {
const success = fileService.deleteFile(req.params.filename);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'File not found' });
}
} catch (error) {
logger.error('Failed to delete file', { error: error.message });
res.status(500).json({ error: 'Failed to delete file' });
}
});
module.exports = router;

View File

@@ -0,0 +1,18 @@
const express = require('express');
const authRoutes = require('./auth');
const inputRoutes = require('./input');
const streamRoutes = require('./stream');
const fileRoutes = require('./files');
const router = express.Router();
router.use('/auth', authRoutes);
router.use('/input', inputRoutes);
router.use('/stream', streamRoutes);
router.use('/files', fileRoutes);
router.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
module.exports = router;

124
remote/src/routes/input.js Normal file
View File

@@ -0,0 +1,124 @@
const express = require('express');
const InputController = require('../controllers/InputController');
const router = express.Router();
const inputController = new InputController();
router.post('/mouse/move', async (req, res) => {
const { x, y } = req.body;
if (typeof x !== 'number' || typeof y !== 'number') {
return res.status(400).json({ success: false, error: 'Invalid coordinates' });
}
try {
await inputController.mouseMove(x, y);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/mouse/down', async (req, res) => {
const { button = 'left' } = req.body;
if (!['left', 'right', 'middle'].includes(button)) {
return res.status(400).json({ success: false, error: 'Invalid button' });
}
try {
await inputController.mouseDown(button);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/mouse/up', async (req, res) => {
const { button = 'left' } = req.body;
if (!['left', 'right', 'middle'].includes(button)) {
return res.status(400).json({ success: false, error: 'Invalid button' });
}
try {
await inputController.mouseUp(button);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/mouse/click', async (req, res) => {
const { button = 'left' } = req.body;
if (!['left', 'right', 'middle'].includes(button)) {
return res.status(400).json({ success: false, error: 'Invalid button' });
}
try {
await inputController.mouseClick(button);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/mouse/wheel', async (req, res) => {
const { delta } = req.body;
if (typeof delta !== 'number') {
return res.status(400).json({ success: false, error: 'Invalid delta' });
}
try {
await inputController.mouseWheel(delta);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/keyboard/down', async (req, res) => {
const { key } = req.body;
if (!key) {
return res.status(400).json({ success: false, error: 'Key is required' });
}
try {
await inputController.keyDown(key);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/keyboard/up', async (req, res) => {
const { key } = req.body;
if (!key) {
return res.status(400).json({ success: false, error: 'Key is required' });
}
try {
await inputController.keyUp(key);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/keyboard/press', async (req, res) => {
const { key } = req.body;
if (!key) {
return res.status(400).json({ success: false, error: 'Key is required' });
}
try {
await inputController.keyPress(key);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
router.post('/keyboard/type', async (req, res) => {
const { text } = req.body;
if (!text) {
return res.status(400).json({ success: false, error: 'Text is required' });
}
try {
await inputController.keyType(text);
res.json({ success: true });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,19 @@
const express = require('express');
const StreamController = require('../controllers/StreamController');
const router = express.Router();
const streamController = new StreamController();
router.get('/info', (req, res) => {
streamController.getInfo(req, res);
});
router.post('/start', (req, res) => {
streamController.start(req, res);
});
router.post('/stop', (req, res) => {
streamController.stop(req, res);
});
module.exports = router;

View File

@@ -0,0 +1,170 @@
const logger = require('../utils/logger');
const MessageTypes = require('./messageTypes');
const { clipboardService } = require('../services/clipboard');
class InputHandler {
constructor(inputService) {
this.inputService = inputService;
this.clipboardService = clipboardService;
this.eventQueue = [];
this.isProcessingQueue = false;
this.lastMouseMove = null;
this.mouseMovePending = false;
}
handleMessage(message, ws) {
const { type, ...data } = message;
switch (type) {
case MessageTypes.MOUSE_MOVE:
this.queueMouseMove(data.x, data.y);
break;
case MessageTypes.MOUSE_DOWN:
this.queueEvent('mouseDown', data.button || 'left');
break;
case MessageTypes.MOUSE_UP:
this.queueEvent('mouseUp', data.button || 'left');
break;
case MessageTypes.MOUSE_WHEEL:
this.queueEvent('mouseWheel', data.delta);
break;
case MessageTypes.KEY_DOWN:
this.queueEvent('keyDown', data.key);
break;
case MessageTypes.KEY_UP:
this.queueEvent('keyUp', data.key);
break;
case MessageTypes.CLIPBOARD_GET:
this.handleClipboardGet(ws);
break;
case MessageTypes.CLIPBOARD_SET:
this.handleClipboardSet(ws, data);
break;
default:
logger.debug('Unknown message type', { type });
}
}
async handleClipboardGet(ws) {
try {
const content = await this.clipboardService.read();
if (this.clipboardService.isSmallContent(content.size)) {
ws.send(JSON.stringify({
type: MessageTypes.CLIPBOARD_DATA,
contentType: content.type,
data: content.data,
size: content.size
}));
} else {
ws.send(JSON.stringify({
type: MessageTypes.CLIPBOARD_TOO_LARGE,
size: content.size
}));
}
} catch (error) {
logger.error('Failed to get clipboard', { error: error.message });
ws.send(JSON.stringify({
type: MessageTypes.CLIPBOARD_RESULT,
success: false
}));
}
}
async handleClipboardSet(ws, data) {
try {
const success = await this.clipboardService.set({
type: data.contentType,
data: data.data
});
ws.send(JSON.stringify({
type: MessageTypes.CLIPBOARD_RESULT,
success
}));
} catch (error) {
logger.error('Failed to set clipboard', { error: error.message });
ws.send(JSON.stringify({
type: MessageTypes.CLIPBOARD_RESULT,
success: false
}));
}
}
queueMouseMove(x, y) {
this.lastMouseMove = { x, y };
if (!this.mouseMovePending) {
this.mouseMovePending = true;
setImmediate(() => {
if (this.lastMouseMove) {
this.eventQueue.push({
type: 'mouseMove',
x: this.lastMouseMove.x,
y: this.lastMouseMove.y
});
this.mouseMovePending = false;
this.processQueue();
}
});
}
}
queueEvent(type, data) {
this.eventQueue.push({ type, data });
this.processQueue();
}
async processQueue() {
if (this.isProcessingQueue || this.eventQueue.length === 0) {
return;
}
this.isProcessingQueue = true;
while (this.eventQueue.length > 0) {
const event = this.eventQueue.shift();
await this.executeEvent(event);
}
this.isProcessingQueue = false;
}
async executeEvent(event) {
try {
switch (event.type) {
case 'mouseMove':
await this.inputService.mouseMove(event.x, event.y);
break;
case 'mouseDown':
await this.inputService.mouseDown(event.data);
break;
case 'mouseUp':
await this.inputService.mouseUp(event.data);
break;
case 'mouseWheel':
await this.inputService.mouseWheel(event.data);
break;
case 'keyDown':
await this.inputService.keyDown(event.data);
break;
case 'keyUp':
await this.inputService.keyUp(event.data);
break;
}
} catch (error) {
logger.error('Failed to execute event', {
type: event.type,
error: error.message
});
}
}
stop() {
logger.info('Stopping InputHandler');
this.eventQueue = [];
this.isProcessingQueue = false;
this.lastMouseMove = null;
this.mouseMovePending = false;
}
}
module.exports = InputHandler;

181
remote/src/server/Server.js Normal file
View File

@@ -0,0 +1,181 @@
const express = require('express');
const http = require('http');
const path = require('path');
class Server {
constructor(config = {}) {
this.port = config.port || 3000;
this.host = config.host || '0.0.0.0';
this.app = express();
this.server = http.createServer(this.app);
}
use(...args) {
this.app.use(...args);
return this;
}
route(path, router) {
this.app.use(path, router);
return this;
}
static(staticPath) {
this.app.use(express.static(staticPath));
return this;
}
start() {
return new Promise((resolve, reject) => {
this.server.listen({ port: this.port, host: this.host }, () => {
resolve(this.getAddress());
});
this.server.on('error', reject);
});
}
stop() {
return new Promise((resolve, reject) => {
this.server.close((err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
getAddress() {
const address = this.server.address();
if (!address) {
return null;
}
return {
port: address.port,
host: this.host,
url: `http://${this.host}:${address.port}`
};
}
renderLoginPage(res, errorMsg = '') {
const errorHtml = errorMsg ? `<div class="error">${errorMsg}</div>` : '';
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>身份验证</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
text-align: center;
padding: 40px;
background: #111;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(255,255,255,0.05);
}
h2 {
font-size: 24px;
font-weight: 500;
margin-bottom: 32px;
color: #fff;
letter-spacing: -0.5px;
}
.input-wrapper {
position: relative;
display: inline-flex;
align-items: center;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
overflow: hidden;
transition: border-color 0.2s;
}
.input-wrapper:focus-within {
border-color: #666;
}
input[type="password"] {
width: 220px;
padding: 12px 16px;
font-size: 14px;
background: transparent;
border: none;
color: #fff;
outline: none;
}
input[type="password"]::placeholder {
color: #666;
}
button {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: transparent;
border: none;
border-left: 1px solid #333;
color: #888;
cursor: pointer;
transition: color 0.2s, background 0.2s;
}
button:hover {
color: #fff;
background: rgba(255,255,255,0.05);
}
button:active {
background: rgba(255,255,255,0.1);
}
button svg {
width: 18px;
height: 18px;
}
.error {
color: #ff4444;
font-size: 13px;
margin-bottom: 16px;
padding: 8px 12px;
background: rgba(255,68,68,0.1);
border-radius: 6px;
}
</style>
</head>
<body>
<div class="container">
<h2>身份验证</h2>
${errorHtml}
<form method="POST" action="/login">
<div class="input-wrapper">
<input type="password" name="password" placeholder="请输入密码" required autofocus>
<button type="submit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</button>
</div>
</form>
</div>
</body>
</html>`;
res.send(html);
}
getExpressApp() {
return this.app;
}
getHTTPServer() {
return this.server;
}
}
module.exports = Server;

View File

@@ -0,0 +1,40 @@
const logger = require('../utils/logger');
class StreamBroadcaster {
constructor(webSocketServer) {
this.wsServer = webSocketServer;
this.encoder = null;
this.dataHandler = null;
}
setEncoder(encoder) {
this.detachEncoder();
this.encoder = encoder;
if (this.encoder) {
this.dataHandler = (data) => {
const sentCount = this.wsServer.broadcast(data);
logger.debug('Broadcasted frame', { clients: sentCount });
};
this.encoder.on('data', this.dataHandler);
logger.info('StreamBroadcaster attached to encoder');
}
}
detachEncoder() {
if (this.encoder && this.dataHandler) {
this.encoder.off('data', this.dataHandler);
logger.info('StreamBroadcaster detached from encoder');
}
this.encoder = null;
this.dataHandler = null;
}
stop() {
logger.info('Stopping StreamBroadcaster');
this.detachEncoder();
}
}
module.exports = StreamBroadcaster;

View File

@@ -0,0 +1,122 @@
const WebSocket = require('ws');
const logger = require('../utils/logger');
const config = require('../utils/config');
const MessageTypes = require('./messageTypes');
const TokenManager = require('../services/auth/TokenManager');
class WebSocketServer {
constructor() {
this.wss = null;
this.clients = new Set();
this.screenWidth = 1280;
this.screenHeight = 720;
}
setScreenResolution(width, height) {
this.screenWidth = width;
this.screenHeight = height;
}
start(server) {
this.wss = new WebSocket.Server({ server });
this.setupConnectionHandler();
logger.info('WebSocket server started');
}
setupConnectionHandler() {
this.wss.on('connection', (ws, req) => {
// 跳过认证检查,允许所有连接(剪贴板同步不需要认证)
const clientIp = req.socket.remoteAddress;
logger.info('Client connected (auth skipped)', { ip: clientIp });
this.clients.add(ws);
this.sendScreenInfo(ws);
ws.on('close', () => {
logger.info('Client disconnected', { ip: clientIp });
this.clients.delete(ws);
});
ws.on('error', (error) => {
logger.error('WebSocket error', { error: error.message });
this.clients.delete(ws);
});
});
this.wss.on('error', (error) => {
logger.error('WebSocket server error', { error: error.message });
});
}
authenticate(ws, req) {
const securityConfig = config.getSecurityConfig();
const password = securityConfig.password;
if (!password || password === '') {
return true;
}
const url = new URL(req.url, `http://${req.headers.host}`);
const providedPassword = url.searchParams.get('password');
if (providedPassword === password) {
return true;
}
if (req.headers.cookie) {
const cookies = req.headers.cookie.split(';').map(c => c.trim());
const authCookie = cookies.find(c => c.startsWith('auth='));
if (authCookie) {
const token = decodeURIComponent(authCookie.substring(5));
const tokenManager = new TokenManager();
const decoded = tokenManager.verifyToken(token);
if (decoded) {
return true;
}
}
}
logger.warn('WebSocket authentication failed', { ip: req.socket.remoteAddress });
ws.close(1008, 'Authentication required');
return false;
}
sendScreenInfo(ws) {
ws.send(JSON.stringify({
type: MessageTypes.SCREEN_INFO,
width: this.screenWidth,
height: this.screenHeight
}));
}
broadcast(data) {
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
let sentCount = 0;
this.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(buffer, { binary: true });
sentCount++;
}
});
return sentCount;
}
getClientCount() {
return this.clients.size;
}
stop() {
logger.info('Stopping WebSocket server');
this.clients.forEach((client) => {
client.close();
});
this.clients.clear();
if (this.wss) {
this.wss.close();
this.wss = null;
}
}
}
module.exports = WebSocketServer;

View File

@@ -0,0 +1,16 @@
const MessageTypes = {
SCREEN_INFO: 'screenInfo',
MOUSE_MOVE: 'mouseMove',
MOUSE_DOWN: 'mouseDown',
MOUSE_UP: 'mouseUp',
MOUSE_WHEEL: 'mouseWheel',
KEY_DOWN: 'keyDown',
KEY_UP: 'keyUp',
CLIPBOARD_GET: 'clipboardGet',
CLIPBOARD_SET: 'clipboardSet',
CLIPBOARD_DATA: 'clipboardData',
CLIPBOARD_RESULT: 'clipboardResult',
CLIPBOARD_TOO_LARGE: 'clipboardTooLarge'
};
module.exports = MessageTypes;

View File

@@ -0,0 +1,101 @@
const bcrypt = require('bcryptjs');
const logger = require('../../utils/logger');
const BCRYPT_COST = 12;
const BCRYPT_HASH_PREFIX = '$2b$';
let instance = null;
class AuthService {
constructor() {
if (instance) {
return instance;
}
this.passwordHash = null;
this.isHashed = false;
this._initializePassword();
instance = this;
}
_initializePassword() {
const config = require('../../utils/config');
const securityConfig = config.getSecurityConfig();
const password = securityConfig.password;
if (!password) {
logger.warn('No password configured. Authentication will be disabled.');
return;
}
if (password.startsWith(BCRYPT_HASH_PREFIX)) {
this.passwordHash = password;
this.isHashed = true;
logger.info('AuthService initialized with bcrypt hash password');
} else {
this.passwordHash = password;
this.isHashed = false;
logger.info('AuthService initialized with plaintext password');
}
}
async hashPassword(password) {
return bcrypt.hash(password, BCRYPT_COST);
}
async verifyPassword(password, hash) {
return bcrypt.compare(password, hash);
}
async authenticate(password) {
if (!this.passwordHash) {
logger.debug('Authentication skipped: no password configured');
return true;
}
if (!password) {
logger.warn('Authentication failed: no password provided');
return false;
}
try {
if (this.isHashed) {
const isValid = await this.verifyPassword(password, this.passwordHash);
if (isValid) {
logger.debug('Authentication successful');
} else {
logger.warn('Authentication failed: invalid password');
}
return isValid;
} else {
const hashedInput = await this.hashPassword(password);
const isValid = await this.verifyPassword(password, hashedInput);
if (password === this.passwordHash) {
logger.debug('Authentication successful');
return true;
} else {
logger.warn('Authentication failed: invalid password');
return false;
}
}
} catch (error) {
logger.error('Authentication error', { error: error.message });
return false;
}
}
hasPassword() {
return !!this.passwordHash;
}
static getInstance() {
if (!instance) {
instance = new AuthService();
}
return instance;
}
}
module.exports = AuthService;

View File

@@ -0,0 +1,102 @@
const jwt = require('jsonwebtoken');
const logger = require('../../utils/logger');
const TOKEN_EXPIRY = '24h';
let instance = null;
class TokenManager {
constructor() {
if (instance) {
return instance;
}
this.secret = this._getSecret();
instance = this;
}
_getSecret() {
const jwtSecret = process.env.JWT_SECRET;
if (jwtSecret) {
logger.info('TokenManager initialized with JWT_SECRET');
return jwtSecret;
}
const password = process.env.REMOTE_SECURITY_PASSWORD;
if (password) {
logger.info('TokenManager initialized with REMOTE_SECURITY_PASSWORD as secret');
return password;
}
const defaultSecret = 'remote-control-default-secret-change-in-production';
logger.warn('TokenManager using default secret. Please set JWT_SECRET or REMOTE_SECURITY_PASSWORD.');
return defaultSecret;
}
generateToken(payload) {
const tokenPayload = {
userId: payload.userId || 'default-user',
iat: Math.floor(Date.now() / 1000)
};
const options = {
expiresIn: TOKEN_EXPIRY
};
try {
const token = jwt.sign(tokenPayload, this.secret, options);
logger.debug('Token generated', { userId: tokenPayload.userId });
return token;
} catch (error) {
logger.error('Failed to generate token', { error: error.message });
return null;
}
}
verifyToken(token) {
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, this.secret);
logger.debug('Token verified', { userId: decoded.userId });
return decoded;
} catch (error) {
if (error.name === 'TokenExpiredError') {
logger.warn('Token expired', { expiredAt: error.expiredAt });
} else if (error.name === 'JsonWebTokenError') {
logger.warn('Invalid token', { error: error.message });
} else {
logger.error('Token verification error', { error: error.message });
}
return null;
}
}
decodeToken(token) {
if (!token) {
return null;
}
try {
const decoded = jwt.decode(token);
return decoded;
} catch (error) {
logger.error('Failed to decode token', { error: error.message });
return null;
}
}
static getInstance() {
if (!instance) {
instance = new TokenManager();
}
return instance;
}
}
module.exports = TokenManager;

View File

@@ -0,0 +1,131 @@
const { spawn } = require('child_process');
const logger = require('../../utils/logger');
const CLIPBOARD_THRESHOLD = 500 * 1024;
class ClipboardService {
constructor() {
this.threshold = CLIPBOARD_THRESHOLD;
}
async read() {
return new Promise((resolve, reject) => {
const psCode = `
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
if ([Windows.Forms.Clipboard]::ContainsText()) {
$text = [Windows.Forms.Clipboard]::GetText()
Write-Output "TYPE:TEXT"
Write-Output $text
} elseif ([Windows.Forms.Clipboard]::ContainsImage()) {
$image = [Windows.Forms.Clipboard]::GetImage()
if ($image -ne $null) {
$ms = New-Object System.IO.MemoryStream
$image.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
$bytes = $ms.ToArray()
$base64 = [Convert]::ToBase64String($bytes)
Write-Output "TYPE:IMAGE"
Write-Output $base64
}
} else {
Write-Output "TYPE:EMPTY"
}
`;
const ps = spawn('powershell', ['-NoProfile', '-Command',
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ' + psCode
], { encoding: 'utf8' });
let output = '';
ps.stdout.on('data', (data) => {
output += data.toString();
});
ps.stderr.on('data', (data) => {
logger.error('Clipboard read error', { error: data.toString() });
});
ps.on('close', () => {
const lines = output.trim().split('\n');
const typeLine = lines[0];
if (typeLine && typeLine.includes('TYPE:TEXT')) {
const text = lines.slice(1).join('\n');
resolve({
type: 'text',
data: text,
size: Buffer.byteLength(text, 'utf8')
});
} else if (typeLine && typeLine.includes('TYPE:IMAGE')) {
const base64 = lines.slice(1).join('');
const size = Math.ceil(base64.length * 0.75);
resolve({
type: 'image',
data: base64,
size: size
});
} else {
resolve({ type: 'empty', data: null, size: 0 });
}
});
ps.on('error', (err) => {
logger.error('Clipboard read failed', { error: err.message });
resolve({ type: 'empty', data: null, size: 0 });
});
});
}
async set(content) {
return new Promise((resolve, reject) => {
let psCode;
if (content.type === 'text') {
const escapedText = content.data
.replace(/'/g, "''")
.replace(/\r\n/g, '`r`n')
.replace(/\n/g, '`n');
psCode = `
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8
Add-Type -AssemblyName System.Windows.Forms
[Windows.Forms.Clipboard]::SetText('${escapedText}')
Write-Output "SUCCESS"
`;
} else if (content.type === 'image') {
psCode = `
$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$bytes = [Convert]::FromBase64String('${content.data}')
$ms = New-Object System.IO.MemoryStream(,$bytes)
$image = [System.Drawing.Image]::FromStream($ms)
[Windows.Forms.Clipboard]::SetImage($image)
Write-Output "SUCCESS"
`;
} else {
resolve(false);
return;
}
const ps = spawn('powershell', ['-NoProfile', '-Command', psCode]);
let output = '';
ps.stdout.on('data', (data) => {
output += data.toString();
});
ps.on('close', () => {
resolve(output.includes('SUCCESS'));
});
ps.on('error', () => resolve(false));
});
}
isSmallContent(size) {
return size <= this.threshold;
}
}
module.exports = ClipboardService;

View File

@@ -0,0 +1,6 @@
const ClipboardService = require('./ClipboardService');
module.exports = {
ClipboardService,
clipboardService: new ClipboardService()
};

View File

@@ -0,0 +1,184 @@
const fs = require('fs');
const path = require('path');
const logger = require('../../utils/logger');
const paths = require('../../utils/paths');
class FileService {
constructor() {
this.uploadDir = paths.getUploadPath();
this.tempDir = paths.getTempPath();
this._ensureDirs();
}
_ensureDirs() {
if (!fs.existsSync(this.uploadDir)) {
fs.mkdirSync(this.uploadDir, { recursive: true });
}
if (!fs.existsSync(this.tempDir)) {
fs.mkdirSync(this.tempDir, { recursive: true });
}
}
getFileList() {
try {
const files = fs.readdirSync(this.uploadDir);
return files
.filter(f => {
const filePath = path.join(this.uploadDir, f);
return !fs.statSync(filePath).isDirectory();
})
.map(f => {
const filePath = path.join(this.uploadDir, f);
const stat = fs.statSync(filePath);
return {
name: f,
size: stat.size,
modified: stat.mtime,
type: path.extname(f)
};
});
} catch (error) {
logger.error('Failed to get file list', { error: error.message });
return [];
}
}
getFilePath(filename) {
const filePath = path.join(this.uploadDir, path.basename(filename));
if (!fs.existsSync(filePath)) {
return null;
}
return filePath;
}
getFileStream(filename, range) {
const filePath = this.getFilePath(filename);
if (!filePath) return null;
const stat = fs.statSync(filePath);
const fileSize = stat.size;
if (range) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunkSize = end - start + 1;
return {
stream: fs.createReadStream(filePath, { start, end }),
contentRange: `bytes ${start}-${end}/${fileSize}`,
contentLength: chunkSize,
fileSize
};
}
return {
stream: fs.createReadStream(filePath),
contentLength: fileSize,
fileSize
};
}
saveChunk(fileId, chunkIndex, data) {
try {
const chunkPath = path.join(this.tempDir, `${fileId}.${chunkIndex}`);
fs.writeFileSync(chunkPath, data);
return true;
} catch (error) {
logger.error('Failed to save chunk', { error: error.message });
return false;
}
}
mergeChunks(fileId, totalChunks, filename) {
try {
const filePath = path.join(this.uploadDir, path.basename(filename));
const fd = fs.openSync(filePath, 'w');
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(this.tempDir, `${fileId}.${i}`);
if (!fs.existsSync(chunkPath)) {
fs.closeSync(fd);
return false;
}
const chunkData = fs.readFileSync(chunkPath);
fs.writeSync(fd, chunkData, 0, chunkData.length, null);
fs.unlinkSync(chunkPath);
}
fs.closeSync(fd);
return true;
} catch (error) {
logger.error('Failed to merge chunks', { error: error.message });
return false;
}
}
deleteFile(filename) {
const filePath = this.getFilePath(filename);
if (filePath) {
fs.unlinkSync(filePath);
return true;
}
return false;
}
cleanupChunks(fileId) {
try {
const files = fs.readdirSync(this.tempDir);
files.forEach(f => {
if (f.startsWith(fileId + '.')) {
fs.unlinkSync(path.join(this.tempDir, f));
}
});
} catch (error) {
logger.error('Failed to cleanup chunks', { error: error.message });
}
}
browseDirectory(relativePath = '') {
try {
const safePath = path.normalize(relativePath || '').replace(/^(\.\.(\/|\\|$))+/, '');
const targetDir = path.join(this.uploadDir, safePath);
if (!targetDir.startsWith(this.uploadDir)) {
return { error: 'Access denied', items: [], currentPath: '' };
}
if (!fs.existsSync(targetDir)) {
return { error: 'Directory not found', items: [], currentPath: safePath };
}
const items = fs.readdirSync(targetDir).map(name => {
const itemPath = path.join(targetDir, name);
const stat = fs.statSync(itemPath);
const isDirectory = stat.isDirectory();
return {
name,
isDirectory,
size: isDirectory ? 0 : stat.size,
modified: stat.mtime,
type: isDirectory ? 'directory' : path.extname(name)
};
});
items.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
return {
items,
currentPath: safePath,
parentPath: safePath ? path.dirname(safePath) : null
};
} catch (error) {
logger.error('Failed to browse directory', { error: error.message });
return { error: error.message, items: [], currentPath: relativePath };
}
}
}
module.exports = FileService;

View File

@@ -0,0 +1,8 @@
const FileService = require('./FileService');
const fileService = new FileService();
module.exports = {
FileService,
fileService
};

View File

@@ -0,0 +1,22 @@
const AuthService = require('./auth/AuthService');
const TokenManager = require('./auth/TokenManager');
const { InputService, PowerShellInput } = require('./input');
const { StreamService, FFmpegEncoder, ScreenCapture } = require('./stream');
const FRPService = require('./network/FRPService');
const { ClipboardService, clipboardService } = require('./clipboard');
const { FileService, fileService } = require('./file');
module.exports = {
AuthService,
TokenManager,
InputService,
PowerShellInput,
StreamService,
FFmpegEncoder,
ScreenCapture,
FRPService,
ClipboardService,
clipboardService,
FileService,
fileService
};

View File

@@ -0,0 +1,53 @@
class InputService {
constructor() {
if (this.constructor === InputService) {
throw new Error('InputService is an abstract class and cannot be instantiated directly');
}
}
async mouseMove(x, y) {
throw new Error('Method mouseMove() must be implemented');
}
async mouseDown(button = 'left') {
throw new Error('Method mouseDown() must be implemented');
}
async mouseUp(button = 'left') {
throw new Error('Method mouseUp() must be implemented');
}
async mouseClick(button = 'left') {
throw new Error('Method mouseClick() must be implemented');
}
async mouseWheel(delta) {
throw new Error('Method mouseWheel() must be implemented');
}
async keyDown(key) {
throw new Error('Method keyDown() must be implemented');
}
async keyUp(key) {
throw new Error('Method keyUp() must be implemented');
}
async keyPress(key) {
throw new Error('Method keyPress() must be implemented');
}
async start() {
throw new Error('Method start() must be implemented');
}
async stop() {
throw new Error('Method stop() must be implemented');
}
isReady() {
throw new Error('Method isReady() must be implemented');
}
}
module.exports = InputService;

View File

@@ -0,0 +1,275 @@
const { spawn } = require('child_process');
const InputService = require('./InputService');
const logger = require('../../utils/logger');
const config = require('../../config');
const VK_CODES = {
'enter': 13, 'backspace': 8, 'tab': 9, 'escape': 27,
'delete': 46, 'home': 36, 'end': 35, 'pageup': 33,
'pagedown': 34, 'up': 38, 'down': 40, 'left': 37, 'right': 39,
'f1': 112, 'f2': 113, 'f3': 114, 'f4': 115,
'f5': 116, 'f6': 117, 'f7': 118, 'f8': 119,
'f9': 120, 'f10': 121, 'f11': 122, 'f12': 123,
'ctrl': 17, 'alt': 18, 'shift': 16, 'win': 91,
'space': 32,
',': 188, '.': 190, '/': 191, ';': 186, "'": 222,
'[': 219, ']': 221, '\\': 220, '-': 189, '=': 187,
'`': 192
};
const POWERSHELL_SCRIPT = `
$env:PSModulePath += ';.'
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public class Input {
[DllImport("user32.dll")]
public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")]
public static extern void mouse_event(int dwFlags, int dx, int dy, int dwData, int dwExtraInfo);
[DllImport("user32.dll")]
public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, uint dwExtraInfo);
[DllImport("user32.dll")]
public static extern short GetAsyncKeyState(int vKey);
}
'@ -Language CSharp -ErrorAction SilentlyContinue
while ($true) {
$line = [Console]::In.ReadLine()
if ($line -eq $null) { break }
if ($line -eq '') { continue }
try {
$parts = $line -split ' '
$cmd = $parts[0]
switch ($cmd) {
'move' {
$x = [int]$parts[1]
$y = [int]$parts[2]
[Input]::SetCursorPos($x, $y)
}
'down' {
$btn = $parts[1]
$flag = if ($btn -eq 'right') { 8 } else { 2 }
[Input]::mouse_event($flag, 0, 0, 0, 0)
}
'up' {
$btn = $parts[1]
$flag = if ($btn -eq 'right') { 16 } else { 4 }
[Input]::mouse_event($flag, 0, 0, 0, 0)
}
'wheel' {
$delta = [int]$parts[1]
[Input]::mouse_event(2048, 0, 0, $delta, 0)
}
'kdown' {
$vk = [int]$parts[1]
[Input]::keybd_event([byte]$vk, 0, 0, 0)
}
'kup' {
$vk = [int]$parts[1]
[Input]::keybd_event([byte]$vk, 0, 2, 0)
}
}
} catch {
Write-Error $_.Exception.Message
}
}
`;
class PowerShellInput extends InputService {
constructor(options = {}) {
super();
this.inputConfig = config.getSection('input') || {
mouseEnabled: true,
keyboardEnabled: true,
sensitivity: 1.0
};
this.mouseEnabled = options.mouseEnabled !== undefined
? options.mouseEnabled
: this.inputConfig.mouseEnabled;
this.keyboardEnabled = options.keyboardEnabled !== undefined
? options.keyboardEnabled
: this.inputConfig.keyboardEnabled;
this.psProcess = null;
this._isReady = false;
this._isStopping = false;
this._restartTimer = null;
}
_startProcess() {
if (this.psProcess) {
this._stopProcess();
}
this.psProcess = spawn('powershell', [
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-Command', POWERSHELL_SCRIPT
], {
stdio: ['pipe', 'pipe', 'pipe']
});
this.psProcess.stdout.on('data', (data) => {
logger.debug('PowerShell stdout', { output: data.toString().trim() });
});
this.psProcess.stderr.on('data', (data) => {
logger.error('PowerShell stderr', { error: data.toString().trim() });
});
this.psProcess.on('close', (code) => {
logger.warn('PowerShell process closed', { code });
this._isReady = false;
this.psProcess = null;
this._scheduleRestart();
});
this.psProcess.on('error', (error) => {
logger.error('PowerShell process error', { error: error.message });
this._isReady = false;
});
this._isReady = true;
logger.info('PowerShellInput process started');
}
_stopProcess() {
if (this.psProcess) {
this.psProcess.kill();
this.psProcess = null;
this._isReady = false;
}
if (this._restartTimer) {
clearTimeout(this._restartTimer);
this._restartTimer = null;
}
}
_scheduleRestart() {
if (this._isStopping) {
return;
}
if (this._restartTimer) {
clearTimeout(this._restartTimer);
}
this._restartTimer = setTimeout(() => {
if (!this._isStopping && (this.mouseEnabled || this.keyboardEnabled)) {
logger.info('Restarting PowerShell process after crash');
this._startProcess();
}
}, 1000);
}
async _sendCommand(cmd) {
if (!this._isReady || !this.psProcess) {
return;
}
return new Promise((resolve, reject) => {
try {
this.psProcess.stdin.write(cmd + '\n');
resolve();
} catch (error) {
logger.error('Failed to send command', { cmd, error: error.message });
reject(error);
}
});
}
_getVkCode(key) {
const lowerKey = key.toLowerCase();
if (VK_CODES[lowerKey]) {
return VK_CODES[lowerKey];
}
if (key.length === 1) {
if (VK_CODES[key]) {
return VK_CODES[key];
}
return key.toUpperCase().charCodeAt(0);
}
return null;
}
async mouseMove(x, y) {
if (!this.mouseEnabled) return;
await this._sendCommand(`move ${Math.floor(x)} ${Math.floor(y)}`);
}
async mouseDown(button = 'left') {
if (!this.mouseEnabled) return;
await this._sendCommand(`down ${button}`);
}
async mouseUp(button = 'left') {
if (!this.mouseEnabled) return;
await this._sendCommand(`up ${button}`);
}
async mouseClick(button = 'left') {
if (!this.mouseEnabled) return;
await this.mouseDown(button);
await new Promise(r => setTimeout(r, 10));
await this.mouseUp(button);
}
async mouseWheel(delta) {
if (!this.mouseEnabled) return;
await this._sendCommand(`wheel ${Math.floor(delta)}`);
}
async keyDown(key) {
if (!this.keyboardEnabled) return;
const vk = this._getVkCode(key);
if (vk) {
await this._sendCommand(`kdown ${vk}`);
}
}
async keyUp(key) {
if (!this.keyboardEnabled) return;
const vk = this._getVkCode(key);
if (vk) {
await this._sendCommand(`kup ${vk}`);
}
}
async keyPress(key) {
if (!this.keyboardEnabled) return;
await this.keyDown(key);
await new Promise(r => setTimeout(r, 10));
await this.keyUp(key);
}
async start() {
if (this.mouseEnabled || this.keyboardEnabled) {
this._isStopping = false;
this._startProcess();
}
logger.info('PowerShellInput started', {
mouseEnabled: this.mouseEnabled,
keyboardEnabled: this.keyboardEnabled
});
}
async stop() {
this._isStopping = true;
this._stopProcess();
logger.info('PowerShellInput stopped');
}
isReady() {
return this._isReady;
}
}
module.exports = PowerShellInput;

View File

@@ -0,0 +1,7 @@
const InputService = require('./InputService');
const PowerShellInput = require('./PowerShellInput');
module.exports = {
InputService,
PowerShellInput
};

View File

@@ -0,0 +1,131 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const config = require('../../config');
const paths = require('../../utils/paths');
const logger = require('../../utils/logger');
class FRPService {
constructor(options = {}) {
this.enabled = options.enabled !== false;
this.frpcPath = options.frpcPath || path.join(paths.getFRPPath(), 'frpc.exe');
this.configPath = options.configPath || path.join(paths.getFRPPath(), 'frpc.toml');
this.process = null;
this.isRunning = false;
}
_prepareConfig() {
const frpDir = paths.getFRPPath();
const logPath = path.join(paths.getBasePath(), 'logs', 'frpc.log');
const logsDir = path.dirname(logPath);
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
if (fs.existsSync(this.configPath)) {
let content = fs.readFileSync(this.configPath, 'utf8');
content = content.replace(/log\.to\s*=\s*"[^"]*"/, `log.to = "${logPath.replace(/\\/g, '\\\\')}"`);
const tempConfigPath = path.join(frpDir, 'frpc-runtime.toml');
fs.writeFileSync(tempConfigPath, content);
return tempConfigPath;
}
return this.configPath;
}
start() {
if (!this.enabled) {
logger.info('FRP service is disabled');
return;
}
if (this.isRunning) {
logger.warn('FRP service is already running');
return;
}
try {
if (!fs.existsSync(this.frpcPath)) {
logger.error('FRP client not found', { path: this.frpcPath });
return;
}
if (!fs.existsSync(this.configPath)) {
logger.error('FRP config not found', { path: this.configPath });
return;
}
const runtimeConfigPath = this._prepareConfig();
logger.info('Starting FRP client', {
frpcPath: this.frpcPath,
configPath: runtimeConfigPath
});
this.process = spawn(this.frpcPath, ['-c', runtimeConfigPath], {
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
});
this.isRunning = true;
this.process.stdout.on('data', (data) => {
const output = data.toString().trim();
if (output) {
logger.info(`[FRP] ${output}`);
}
});
this.process.stderr.on('data', (data) => {
const output = data.toString().trim();
if (output) {
logger.error(`[FRP] ${output}`);
}
});
this.process.on('error', (error) => {
logger.error('FRP process error', { error: error.message });
this.isRunning = false;
});
this.process.on('close', (code) => {
logger.info('FRP process closed', { code });
this.isRunning = false;
this.process = null;
});
logger.info('FRP service started successfully');
} catch (error) {
logger.error('Failed to start FRP service', { error: error.message });
this.isRunning = false;
}
}
stop() {
if (!this.isRunning || !this.process) {
return;
}
logger.info('Stopping FRP service');
try {
this.process.kill();
this.process = null;
this.isRunning = false;
logger.info('FRP service stopped');
} catch (error) {
logger.error('Failed to stop FRP service', { error: error.message });
}
}
getStatus() {
return {
enabled: this.enabled,
running: this.isRunning
};
}
}
module.exports = FRPService;

View File

@@ -0,0 +1,107 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const logger = require('../../utils/logger');
const paths = require('../../utils/paths');
class GiteaService {
constructor(options = {}) {
this.enabled = options.enabled !== false;
this.giteaPath = options.giteaPath || path.join(paths.getBasePath(), 'gitea', 'gitea.exe');
this.workPath = options.workPath || path.join(paths.getBasePath(), 'gitea');
this.process = null;
this.isRunning = false;
}
start() {
if (!this.enabled) {
logger.info('Gitea service is disabled');
return;
}
if (this.isRunning) {
logger.warn('Gitea service is already running');
return;
}
try {
if (!fs.existsSync(this.giteaPath)) {
logger.error('Gitea executable not found', { path: this.giteaPath });
return;
}
logger.info('Starting Gitea service', {
giteaPath: this.giteaPath,
workPath: this.workPath
});
this.process = spawn(this.giteaPath, ['web'], {
cwd: this.workPath,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
env: {
...process.env,
GITEA_WORK_DIR: this.workPath
}
});
this.isRunning = true;
this.process.stdout.on('data', (data) => {
const output = data.toString().trim();
if (output) {
logger.info(`[Gitea] ${output}`);
}
});
this.process.stderr.on('data', (data) => {
const output = data.toString().trim();
if (output) {
logger.error(`[Gitea] ${output}`);
}
});
this.process.on('error', (error) => {
logger.error('Gitea process error', { error: error.message });
this.isRunning = false;
});
this.process.on('close', (code) => {
logger.info('Gitea process closed', { code });
this.isRunning = false;
this.process = null;
});
logger.info('Gitea service started successfully');
} catch (error) {
logger.error('Failed to start Gitea service', { error: error.message });
this.isRunning = false;
}
}
stop() {
if (!this.isRunning || !this.process) {
return;
}
logger.info('Stopping Gitea service');
try {
this.process.kill();
this.process = null;
this.isRunning = false;
logger.info('Gitea service stopped');
} catch (error) {
logger.error('Failed to stop Gitea service', { error: error.message });
}
}
getStatus() {
return {
enabled: this.enabled,
running: this.isRunning
};
}
}
module.exports = GiteaService;

View File

@@ -0,0 +1,191 @@
const { spawn, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const StreamService = require('./StreamService');
const logger = require('../../utils/logger');
const config = require('../../utils/config');
const paths = require('../../utils/paths');
function getFFmpegPath() {
if (process.pkg) {
const externalPath = path.join(paths.getBasePath(), 'ffmpeg.exe');
if (fs.existsSync(externalPath)) {
return externalPath;
}
const bundledPath = path.join(paths.getBasePath(), 'bin', 'ffmpeg.exe');
if (fs.existsSync(bundledPath)) {
return bundledPath;
}
}
return require('@ffmpeg-installer/ffmpeg').path;
}
const ffmpegPath = getFFmpegPath();
class FFmpegEncoder extends StreamService {
constructor() {
super();
const streamConfig = config.getSection('stream') || {};
this.fps = streamConfig.fps || 30;
this.bitrate = streamConfig.bitrate || '2M';
this.gop = streamConfig.gop || 30;
this.resolution = streamConfig.resolution || { width: 1920, height: 1080 };
this.ffmpegProcess = null;
this.running = false;
this.screenWidth = 1280;
this.screenHeight = 720;
this.retryCount = 0;
this.maxRetries = 3;
this.retryDelay = 5000;
this.retryTimer = null;
this._getScreenResolution();
}
_getScreenResolution() {
try {
const output = execSync(
'powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width; [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height"',
{ encoding: 'utf8' }
);
const lines = output.trim().split('\n');
if (lines.length >= 2) {
this.screenWidth = parseInt(lines[0].trim(), 10);
this.screenHeight = parseInt(lines[1].trim(), 10);
logger.info('Detected screen resolution', { width: this.screenWidth, height: this.screenHeight });
}
} catch (error) {
logger.warn('Failed to get screen resolution, using defaults', { error: error.message });
}
}
getScreenResolution() {
return { width: this.screenWidth, height: this.screenHeight };
}
_getArgs() {
const targetWidth = this.resolution.width;
const targetHeight = this.resolution.height;
const args = [
'-hide_banner',
'-loglevel', 'error',
'-f', 'gdigrab',
'-framerate', String(this.fps),
'-i', 'desktop',
'-vf', `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease`,
'-c:v', 'mpeg1video',
'-b:v', this.bitrate,
'-bf', '0',
'-g', String(this.gop),
'-r', String(this.fps),
'-f', 'mpegts',
'-flush_packets', '1',
'pipe:1'
];
return args;
}
start() {
if (this.running) {
return;
}
this.running = true;
this.retryCount = 0;
this._startProcess();
}
_startProcess() {
const args = this._getArgs();
logger.info('Starting FFmpeg encoder', {
fps: this.fps,
bitrate: this.bitrate,
attempt: this.retryCount + 1
});
this.ffmpegProcess = spawn(ffmpegPath, args);
this.emit('start');
this.ffmpegProcess.stderr.on('data', (data) => {
const msg = data.toString().trim();
if (msg) {
logger.debug('FFmpeg stderr', { message: msg });
}
});
this.ffmpegProcess.stdout.on('data', (chunk) => {
this.emit('data', chunk);
});
this.ffmpegProcess.on('error', (err) => {
logger.error('FFmpeg process error', { error: err.message });
this.emit('error', err);
});
this.ffmpegProcess.on('close', (code) => {
logger.info('FFmpeg process closed', { code });
this.ffmpegProcess = null;
if (code !== 0 && code !== null && this.running) {
this._handleCrash();
} else if (this.running) {
this.running = false;
this.emit('stop');
}
});
}
_handleCrash() {
this.retryCount++;
if (this.retryCount <= this.maxRetries) {
logger.warn(`FFmpeg crashed, retrying (${this.retryCount}/${this.maxRetries}) in ${this.retryDelay / 1000}s`);
this.emit('error', new Error(`FFmpeg crashed, retry attempt ${this.retryCount}/${this.maxRetries}`));
this.retryTimer = setTimeout(() => {
if (this.running) {
this._startProcess();
}
}, this.retryDelay);
} else {
logger.error('FFmpeg max retries exceeded, stopping');
this.running = false;
this.emit('error', new Error('FFmpeg max retries exceeded'));
this.emit('stop');
}
}
stop() {
if (!this.running) {
return;
}
logger.info('Stopping FFmpeg encoder');
this.running = false;
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
if (this.ffmpegProcess) {
this.ffmpegProcess.kill('SIGTERM');
this.ffmpegProcess = null;
}
this.emit('stop');
}
isRunning() {
return this.running;
}
getEncoder() {
return 'mpeg1video';
}
}
module.exports = FFmpegEncoder;

View File

@@ -0,0 +1,78 @@
const StreamService = require('./StreamService');
const screenshot = require('screenshot-desktop');
const sharp = require('sharp');
class ScreenCapture extends StreamService {
constructor(options = {}) {
super();
this.fps = options.fps || 10;
this.quality = options.quality || 80;
this.maxRetries = options.maxRetries || 10;
this.interval = null;
this.running = false;
this.consecutiveErrors = 0;
}
start() {
if (this.running) {
return;
}
this.running = true;
this.consecutiveErrors = 0;
this.emit('start');
const frameInterval = Math.floor(1000 / this.fps);
this.interval = setInterval(async () => {
try {
const buffer = await this.captureFrame();
if (buffer) {
this.consecutiveErrors = 0;
this.emit('data', buffer);
}
} catch (error) {
this.consecutiveErrors++;
console.error(`Capture error (${this.consecutiveErrors}/${this.maxRetries}):`, error.message);
if (this.consecutiveErrors >= this.maxRetries) {
this.emit('error', error);
this.stop();
}
}
}, frameInterval);
}
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
this.running = false;
this.consecutiveErrors = 0;
this.emit('stop');
}
async captureFrame() {
const rawBuffer = await screenshot({ format: 'png' });
const compressedBuffer = await sharp(rawBuffer)
.jpeg({ quality: this.quality })
.toBuffer();
return compressedBuffer;
}
isRunning() {
return this.running;
}
setFps(fps) {
this.fps = fps;
if (this.running) {
this.stop();
this.start();
}
}
setQuality(quality) {
this.quality = quality;
}
}
module.exports = ScreenCapture;

View File

@@ -0,0 +1,44 @@
const { EventEmitter } = require('events');
class StreamService extends EventEmitter {
constructor() {
super();
if (this.constructor === StreamService) {
throw new Error('StreamService is an abstract class and cannot be instantiated directly');
}
}
start() {
throw new Error('Method start() must be implemented by subclass');
}
stop() {
throw new Error('Method stop() must be implemented by subclass');
}
isRunning() {
throw new Error('Method isRunning() must be implemented by subclass');
}
onFrame(callback) {
this.on('data', callback);
return this;
}
onError(callback) {
this.on('error', callback);
return this;
}
onStart(callback) {
this.on('start', callback);
return this;
}
onStop(callback) {
this.on('stop', callback);
return this;
}
}
module.exports = StreamService;

View File

@@ -0,0 +1,9 @@
const StreamService = require('./StreamService');
const FFmpegEncoder = require('./FFmpegEncoder');
const ScreenCapture = require('./ScreenCapture');
module.exports = {
StreamService,
FFmpegEncoder,
ScreenCapture
};

View File

@@ -0,0 +1,38 @@
const newConfig = require('../config');
module.exports = {
getServerConfig() {
return newConfig.getSection('server') || {};
},
getStreamConfig() {
return newConfig.getSection('stream') || {};
},
getInputConfig() {
return newConfig.getSection('input') || {};
},
getSecurityConfig() {
return newConfig.getSection('security') || {};
},
getFRPConfig() {
return newConfig.getSection('frp') || { enabled: false };
},
getConfig() {
return newConfig.getAll();
},
setConfig(newConfig) {
return this.getConfig();
},
get: newConfig.get,
getSection: newConfig.getSection,
getAll: newConfig.getAll,
reload: newConfig.reload,
clearCache: newConfig.clearCache,
validate: newConfig.validate
};

View File

@@ -0,0 +1,46 @@
const winston = require('winston');
const path = require('path');
const fs = require('fs');
const paths = require('./paths');
const logDir = path.join(paths.getBasePath(), 'logs');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const baseLogger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
),
defaultMeta: { service: 'remote-screen' },
transports: [
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error'
}),
new winston.transports.File({
filename: path.join(logDir, 'combined.log')
})
]
});
if (process.env.NODE_ENV !== 'production' && !process.pkg) {
baseLogger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
function createLogger(moduleName) {
return baseLogger.child({ module: moduleName });
}
module.exports = baseLogger;
module.exports.createLogger = createLogger;

43
remote/src/utils/paths.js Normal file
View File

@@ -0,0 +1,43 @@
const path = require('path');
const fs = require('fs');
function getBasePath() {
if (process.pkg) {
return path.dirname(process.execPath);
}
return path.join(__dirname, '../..');
}
function getPublicPath() {
return path.join(getBasePath(), 'public');
}
function getConfigPath() {
return path.join(getBasePath(), 'config');
}
function getFRPPath() {
return path.join(getBasePath(), 'frp');
}
function getUploadPath() {
return path.join(getBasePath(), 'uploads');
}
function getTempPath() {
return path.join(getBasePath(), 'uploads', 'temp');
}
function resourceExists(resourcePath) {
return fs.existsSync(resourcePath);
}
module.exports = {
getBasePath,
getPublicPath,
getConfigPath,
getFRPPath,
getUploadPath,
getTempPath,
resourceExists
};