Initial commit
This commit is contained in:
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
|
||||
};
|
||||
Reference in New Issue
Block a user