Initial commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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