Initial commit
This commit is contained in:
153
remote/src/config/index.js
Normal file
153
remote/src/config/index.js
Normal 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
201
remote/src/config/schema.js
Normal 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
|
||||
};
|
||||
97
remote/src/controllers/AuthController.js
Normal file
97
remote/src/controllers/AuthController.js
Normal 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;
|
||||
86
remote/src/controllers/InputController.js
Normal file
86
remote/src/controllers/InputController.js
Normal 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;
|
||||
102
remote/src/controllers/StreamController.js
Normal file
102
remote/src/controllers/StreamController.js
Normal 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
454
remote/src/core/App.js
Normal 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;
|
||||
77
remote/src/core/Container.js
Normal file
77
remote/src/core/Container.js
Normal 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;
|
||||
81
remote/src/core/ErrorHandler.js
Normal file
81
remote/src/core/ErrorHandler.js
Normal 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
100
remote/src/core/EventBus.js
Normal 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
27
remote/src/core/events.js
Normal 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
37
remote/src/index.js
Normal 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;
|
||||
80
remote/src/middlewares/auth.js
Normal file
80
remote/src/middlewares/auth.js
Normal 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;
|
||||
78
remote/src/middlewares/error.js
Normal file
78
remote/src/middlewares/error.js
Normal 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
|
||||
};
|
||||
87
remote/src/middlewares/rateLimit.js
Normal file
87
remote/src/middlewares/rateLimit.js
Normal 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
15
remote/src/routes/auth.js
Normal 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
135
remote/src/routes/files.js
Normal 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;
|
||||
18
remote/src/routes/index.js
Normal file
18
remote/src/routes/index.js
Normal 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
124
remote/src/routes/input.js
Normal 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;
|
||||
19
remote/src/routes/stream.js
Normal file
19
remote/src/routes/stream.js
Normal 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;
|
||||
170
remote/src/server/InputHandler.js
Normal file
170
remote/src/server/InputHandler.js
Normal 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
181
remote/src/server/Server.js
Normal 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;
|
||||
40
remote/src/server/StreamBroadcaster.js
Normal file
40
remote/src/server/StreamBroadcaster.js
Normal 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;
|
||||
122
remote/src/server/WebSocketServer.js
Normal file
122
remote/src/server/WebSocketServer.js
Normal 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;
|
||||
16
remote/src/server/messageTypes.js
Normal file
16
remote/src/server/messageTypes.js
Normal 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;
|
||||
101
remote/src/services/auth/AuthService.js
Normal file
101
remote/src/services/auth/AuthService.js
Normal 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;
|
||||
102
remote/src/services/auth/TokenManager.js
Normal file
102
remote/src/services/auth/TokenManager.js
Normal 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;
|
||||
131
remote/src/services/clipboard/ClipboardService.js
Normal file
131
remote/src/services/clipboard/ClipboardService.js
Normal 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;
|
||||
6
remote/src/services/clipboard/index.js
Normal file
6
remote/src/services/clipboard/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const ClipboardService = require('./ClipboardService');
|
||||
|
||||
module.exports = {
|
||||
ClipboardService,
|
||||
clipboardService: new ClipboardService()
|
||||
};
|
||||
184
remote/src/services/file/FileService.js
Normal file
184
remote/src/services/file/FileService.js
Normal 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;
|
||||
8
remote/src/services/file/index.js
Normal file
8
remote/src/services/file/index.js
Normal file
@@ -0,0 +1,8 @@
|
||||
const FileService = require('./FileService');
|
||||
|
||||
const fileService = new FileService();
|
||||
|
||||
module.exports = {
|
||||
FileService,
|
||||
fileService
|
||||
};
|
||||
22
remote/src/services/index.js
Normal file
22
remote/src/services/index.js
Normal 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
|
||||
};
|
||||
53
remote/src/services/input/InputService.js
Normal file
53
remote/src/services/input/InputService.js
Normal 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;
|
||||
275
remote/src/services/input/PowerShellInput.js
Normal file
275
remote/src/services/input/PowerShellInput.js
Normal 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;
|
||||
7
remote/src/services/input/index.js
Normal file
7
remote/src/services/input/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
const InputService = require('./InputService');
|
||||
const PowerShellInput = require('./PowerShellInput');
|
||||
|
||||
module.exports = {
|
||||
InputService,
|
||||
PowerShellInput
|
||||
};
|
||||
131
remote/src/services/network/FRPService.js
Normal file
131
remote/src/services/network/FRPService.js
Normal 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;
|
||||
107
remote/src/services/network/GiteaService.js
Normal file
107
remote/src/services/network/GiteaService.js
Normal 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;
|
||||
191
remote/src/services/stream/FFmpegEncoder.js
Normal file
191
remote/src/services/stream/FFmpegEncoder.js
Normal 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;
|
||||
78
remote/src/services/stream/ScreenCapture.js
Normal file
78
remote/src/services/stream/ScreenCapture.js
Normal 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;
|
||||
44
remote/src/services/stream/StreamService.js
Normal file
44
remote/src/services/stream/StreamService.js
Normal 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;
|
||||
9
remote/src/services/stream/index.js
Normal file
9
remote/src/services/stream/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const StreamService = require('./StreamService');
|
||||
const FFmpegEncoder = require('./FFmpegEncoder');
|
||||
const ScreenCapture = require('./ScreenCapture');
|
||||
|
||||
module.exports = {
|
||||
StreamService,
|
||||
FFmpegEncoder,
|
||||
ScreenCapture
|
||||
};
|
||||
38
remote/src/utils/config.js
Normal file
38
remote/src/utils/config.js
Normal 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
|
||||
};
|
||||
46
remote/src/utils/logger.js
Normal file
46
remote/src/utils/logger.js
Normal 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
43
remote/src/utils/paths.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user