Compare commits
8 Commits
23dd010a95
...
7b9362756d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b9362756d | ||
|
|
ad9b866afe | ||
|
|
f13bf7f5bc | ||
|
|
d1513a58b3 | ||
|
|
90bb298dbe | ||
|
|
9fa047b399 | ||
|
|
c2932a1577 | ||
|
|
92e94b1e8a |
@ -75,6 +75,12 @@ const DEFAULTS = {
|
|||||||
delaySec: 3,
|
delaySec: 3,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
hosters: [] // pre-selected hosters, empty = ask via modal
|
hosters: [] // pre-selected hosters, empty = ask via modal
|
||||||
|
},
|
||||||
|
remote: {
|
||||||
|
enabled: false,
|
||||||
|
port: 9100,
|
||||||
|
token: '',
|
||||||
|
allowInput: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
history: []
|
history: []
|
||||||
|
|||||||
20
lib/remote-capture-preload.js
Normal file
20
lib/remote-capture-preload.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('capture', {
|
||||||
|
// Get capture source ID from main process (desktopCapturer runs in main)
|
||||||
|
getSourceId: () => ipcRenderer.invoke('remote:get-capture-source-id'),
|
||||||
|
|
||||||
|
// Signaling: receive offer/ICE from main process (relayed from dashboard)
|
||||||
|
onSignaling: (callback) => {
|
||||||
|
ipcRenderer.on('remote:signaling-to-capture', (_event, data) => callback(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
// Signaling: send answer/ICE back to main process (relayed to dashboard)
|
||||||
|
sendSignaling: (data) => ipcRenderer.send('remote:signaling-from-capture', data),
|
||||||
|
|
||||||
|
// Input: forward input events from DataChannel to main process
|
||||||
|
sendInput: (data) => ipcRenderer.send('remote:input-event', data),
|
||||||
|
|
||||||
|
// Notify main process of client connection/disconnection
|
||||||
|
notifyClientCount: (count) => ipcRenderer.send('remote:client-count', count)
|
||||||
|
});
|
||||||
124
lib/remote-capture.html
Normal file
124
lib/remote-capture.html
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Remote Capture</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
// Maps clientId -> { pc: RTCPeerConnection, dc: RTCDataChannel }
|
||||||
|
const clients = new Map();
|
||||||
|
let captureStream = null;
|
||||||
|
|
||||||
|
async function getCaptureStream() {
|
||||||
|
if (captureStream) return captureStream;
|
||||||
|
|
||||||
|
// desktopCapturer runs in main process (Electron 33+), we get the source ID via IPC
|
||||||
|
const sourceId = await window.capture.getSourceId();
|
||||||
|
if (!sourceId) throw new Error('No capture source ID from main process');
|
||||||
|
|
||||||
|
captureStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
mandatory: {
|
||||||
|
chromeMediaSource: 'desktop',
|
||||||
|
chromeMediaSourceId: sourceId,
|
||||||
|
minWidth: 1280,
|
||||||
|
maxWidth: 1920,
|
||||||
|
minHeight: 720,
|
||||||
|
maxHeight: 1080,
|
||||||
|
maxFrameRate: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return captureStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOffer(clientId, offer, role) {
|
||||||
|
const stream = await getCaptureStream();
|
||||||
|
|
||||||
|
const pc = new RTCPeerConnection({ iceServers: [] });
|
||||||
|
clients.set(clientId, { pc, role });
|
||||||
|
|
||||||
|
// Add video tracks
|
||||||
|
for (const track of stream.getTracks()) {
|
||||||
|
pc.addTrack(track, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle DataChannel from dashboard (dashboard creates it as offerer)
|
||||||
|
pc.ondatachannel = (event) => {
|
||||||
|
const dc = event.channel;
|
||||||
|
clients.get(clientId).dc = dc;
|
||||||
|
dc.onmessage = (msg) => {
|
||||||
|
try {
|
||||||
|
const input = JSON.parse(msg.data);
|
||||||
|
input.clientId = clientId;
|
||||||
|
input.role = role;
|
||||||
|
window.capture.sendInput(input);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ICE candidates
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
window.capture.sendSignaling({
|
||||||
|
type: 'ice-candidate',
|
||||||
|
clientId,
|
||||||
|
candidate: event.candidate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
|
||||||
|
removeClient(clientId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
|
||||||
|
window.capture.sendSignaling({
|
||||||
|
type: 'answer',
|
||||||
|
clientId,
|
||||||
|
answer: pc.localDescription
|
||||||
|
});
|
||||||
|
|
||||||
|
window.capture.notifyClientCount(clients.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIceCandidate(clientId, candidate) {
|
||||||
|
const client = clients.get(clientId);
|
||||||
|
if (client && client.pc) {
|
||||||
|
client.pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeClient(clientId) {
|
||||||
|
const client = clients.get(clientId);
|
||||||
|
if (client) {
|
||||||
|
if (client.dc) client.dc.close();
|
||||||
|
client.pc.close();
|
||||||
|
clients.delete(clientId);
|
||||||
|
window.capture.notifyClientCount(clients.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for signaling messages from main process
|
||||||
|
window.capture.onSignaling((data) => {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'offer':
|
||||||
|
handleOffer(data.clientId, data.offer, data.role).catch(err => {
|
||||||
|
console.error('Failed to handle offer:', err);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'ice-candidate':
|
||||||
|
handleIceCandidate(data.clientId, data.candidate);
|
||||||
|
break;
|
||||||
|
case 'client-disconnected':
|
||||||
|
removeClient(data.clientId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
171
lib/remote-server.js
Normal file
171
lib/remote-server.js
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
class RemoteServer {
|
||||||
|
constructor() {
|
||||||
|
this._wss = null;
|
||||||
|
this._clients = new Map(); // ws -> { id, role, authenticated }
|
||||||
|
this._config = null;
|
||||||
|
this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
|
||||||
|
}
|
||||||
|
|
||||||
|
start(opts) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._config = opts;
|
||||||
|
|
||||||
|
this._wss = new WebSocketServer({ port: opts.port }, () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._wss.on('error', (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._wss.on('connection', (ws, req) => {
|
||||||
|
this._handleConnection(ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this._wss) {
|
||||||
|
for (const [ws] of this._clients) {
|
||||||
|
ws.close(1000, 'Server shutting down');
|
||||||
|
}
|
||||||
|
this._clients.clear();
|
||||||
|
this._wss.close();
|
||||||
|
this._wss = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getClientCount() {
|
||||||
|
let count = 0;
|
||||||
|
for (const [, client] of this._clients) {
|
||||||
|
if (client.authenticated) count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPort() {
|
||||||
|
if (this._wss && this._wss.address()) {
|
||||||
|
return this._wss.address().port;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleConnection(ws, req) {
|
||||||
|
const ip = req.socket.remoteAddress || 'unknown';
|
||||||
|
|
||||||
|
if (this._isBlocked(ip)) {
|
||||||
|
ws.close(4003, 'Too many failed attempts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = crypto.randomUUID();
|
||||||
|
this._clients.set(ws, { id: clientId, role: null, authenticated: false });
|
||||||
|
|
||||||
|
let authReceived = false;
|
||||||
|
const authTimeout = setTimeout(() => {
|
||||||
|
if (!authReceived) {
|
||||||
|
ws.close(4001, 'Auth timeout');
|
||||||
|
this._clients.delete(ws);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(raw); } catch { return; }
|
||||||
|
|
||||||
|
const client = this._clients.get(ws);
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
if (!client.authenticated) {
|
||||||
|
authReceived = true;
|
||||||
|
clearTimeout(authTimeout);
|
||||||
|
|
||||||
|
if (msg.type === 'auth' && msg.token === this._config.token) {
|
||||||
|
client.authenticated = true;
|
||||||
|
client.role = msg.role || 'viewer';
|
||||||
|
ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
|
||||||
|
|
||||||
|
if (this.getClientCount() === 1) {
|
||||||
|
this._config.onCreateCaptureWindow();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._recordFailedAttempt(ip);
|
||||||
|
ws.close(4002, 'Invalid token');
|
||||||
|
this._clients.delete(ws);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'offer' || msg.type === 'ice-candidate') {
|
||||||
|
msg.clientId = client.id;
|
||||||
|
msg.role = client.role;
|
||||||
|
this._config.onSignalingToCapture(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
clearTimeout(authTimeout);
|
||||||
|
const client = this._clients.get(ws);
|
||||||
|
const wasAuthenticated = client && client.authenticated;
|
||||||
|
this._clients.delete(ws);
|
||||||
|
|
||||||
|
if (wasAuthenticated) {
|
||||||
|
this._config.onSignalingToCapture({
|
||||||
|
type: 'client-disconnected',
|
||||||
|
clientId: client.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.getClientCount() === 0) {
|
||||||
|
this._config.onDestroyCaptureWindow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', () => {
|
||||||
|
this._clients.delete(ws);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToClient(clientId, data) {
|
||||||
|
for (const [ws, client] of this._clients) {
|
||||||
|
if (client.id === clientId && client.authenticated) {
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(data) {
|
||||||
|
const msg = JSON.stringify(data);
|
||||||
|
for (const [ws, client] of this._clients) {
|
||||||
|
if (client.authenticated && ws.readyState === 1) {
|
||||||
|
ws.send(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isBlocked(ip) {
|
||||||
|
const entry = this._failedAttempts.get(ip);
|
||||||
|
if (!entry) return false;
|
||||||
|
if (entry.blockedUntil && Date.now() < entry.blockedUntil) return true;
|
||||||
|
if (entry.blockedUntil && Date.now() >= entry.blockedUntil) {
|
||||||
|
this._failedAttempts.delete(ip);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_recordFailedAttempt(ip) {
|
||||||
|
const entry = this._failedAttempts.get(ip) || { count: 0, blockedUntil: null };
|
||||||
|
entry.count++;
|
||||||
|
if (entry.count >= 5) {
|
||||||
|
entry.blockedUntil = Date.now() + 60000;
|
||||||
|
}
|
||||||
|
this._failedAttempts.set(ip, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RemoteServer;
|
||||||
217
main.js
217
main.js
@ -11,6 +11,7 @@ const DoodstreamUploader = require('./lib/doodstream-upload');
|
|||||||
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
|
const { checkForUpdate, installUpdate, abortUpdate } = require('./lib/updater');
|
||||||
const backupCrypto = require('./lib/backup-crypto');
|
const backupCrypto = require('./lib/backup-crypto');
|
||||||
const FolderMonitor = require('./lib/folder-monitor');
|
const FolderMonitor = require('./lib/folder-monitor');
|
||||||
|
const RemoteServer = require('./lib/remote-server');
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let dropTargetWindow = null;
|
let dropTargetWindow = null;
|
||||||
@ -18,6 +19,8 @@ let tray = null;
|
|||||||
const configStore = new ConfigStore(app);
|
const configStore = new ConfigStore(app);
|
||||||
let uploadManager = null;
|
let uploadManager = null;
|
||||||
let folderMonitor = new FolderMonitor();
|
let folderMonitor = new FolderMonitor();
|
||||||
|
let remoteServer = null;
|
||||||
|
let captureWindow = null;
|
||||||
const HEALTH_CHECK_TIMEOUT = 25000;
|
const HEALTH_CHECK_TIMEOUT = 25000;
|
||||||
|
|
||||||
// --- Debug logging (writes to upload-debug.log next to the app) ---
|
// --- Debug logging (writes to upload-debug.log next to the app) ---
|
||||||
@ -488,6 +491,18 @@ app.whenReady().then(() => {
|
|||||||
debugLog(`folder-monitor auto-start failed: ${err.message}`);
|
debugLog(`folder-monitor auto-start failed: ${err.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-start remote server if enabled
|
||||||
|
try {
|
||||||
|
const remoteConfig = configStore.load().globalSettings && configStore.load().globalSettings.remote;
|
||||||
|
if (remoteConfig && remoteConfig.enabled) {
|
||||||
|
startRemoteServer().catch(err => {
|
||||||
|
debugLog(`remote-server auto-start failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debugLog(`remote-server auto-start failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-show drop target if enabled
|
// Auto-show drop target if enabled
|
||||||
try {
|
try {
|
||||||
const dtConfig = configStore.load();
|
const dtConfig = configStore.load();
|
||||||
@ -517,6 +532,10 @@ app.on('window-all-closed', () => {
|
|||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
try { folderMonitor.stop(); } catch {}
|
try { folderMonitor.stop(); } catch {}
|
||||||
|
try {
|
||||||
|
if (remoteServer) { remoteServer.stop(); remoteServer = null; }
|
||||||
|
destroyCaptureWindow();
|
||||||
|
} catch {}
|
||||||
destroyDropTargetWindow();
|
destroyDropTargetWindow();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -891,6 +910,204 @@ ipcMain.handle('folder-monitor:select-folder', async () => {
|
|||||||
return result.filePaths[0];
|
return result.filePaths[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Remote Control ---
|
||||||
|
function generateToken() {
|
||||||
|
const crypto = require('crypto');
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCaptureWindow() {
|
||||||
|
if (captureWindow && !captureWindow.isDestroyed()) return;
|
||||||
|
captureWindow = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
preload: path.join(__dirname, 'lib', 'remote-capture-preload.js')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
captureWindow.loadFile(path.join(__dirname, 'lib', 'remote-capture.html'));
|
||||||
|
|
||||||
|
// Crash recovery: if hidden window closes unexpectedly while clients connected, recreate it
|
||||||
|
captureWindow.on('closed', () => {
|
||||||
|
captureWindow = null;
|
||||||
|
if (remoteServer && remoteServer.getClientCount() > 0) {
|
||||||
|
debugLog('remote: capture window crashed, recreating...');
|
||||||
|
createCaptureWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyCaptureWindow() {
|
||||||
|
if (captureWindow && !captureWindow.isDestroyed()) {
|
||||||
|
captureWindow.close();
|
||||||
|
captureWindow = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRemoteServer() {
|
||||||
|
if (remoteServer) {
|
||||||
|
remoteServer.stop();
|
||||||
|
remoteServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = configStore.load();
|
||||||
|
const remote = config.globalSettings && config.globalSettings.remote;
|
||||||
|
if (!remote || !remote.enabled) return;
|
||||||
|
|
||||||
|
let token = remote.token;
|
||||||
|
if (!token) {
|
||||||
|
token = generateToken();
|
||||||
|
const gs = { ...config.globalSettings, remote: { ...remote, token } };
|
||||||
|
await configStore.save({ globalSettings: gs });
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteServer = new RemoteServer();
|
||||||
|
await remoteServer.start({
|
||||||
|
port: remote.port || 9100,
|
||||||
|
token,
|
||||||
|
allowInput: remote.allowInput !== false,
|
||||||
|
mainWindow,
|
||||||
|
onSignalingToCapture: (data) => {
|
||||||
|
if (captureWindow && !captureWindow.isDestroyed()) {
|
||||||
|
captureWindow.webContents.send('remote:signaling-to-capture', data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCreateCaptureWindow: () => createCaptureWindow(),
|
||||||
|
onDestroyCaptureWindow: () => destroyCaptureWindow()
|
||||||
|
});
|
||||||
|
|
||||||
|
debugLog(`remote-server started on port ${remoteServer.getPort()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC: Signaling from capture window back to dashboard client
|
||||||
|
ipcMain.on('remote:signaling-from-capture', (_event, data) => {
|
||||||
|
if (remoteServer && data.clientId) {
|
||||||
|
remoteServer.sendToClient(data.clientId, data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// IPC: Input events from capture window
|
||||||
|
ipcMain.on('remote:input-event', (_event, data) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
|
||||||
|
const config = configStore.load();
|
||||||
|
const remote = config.globalSettings && config.globalSettings.remote;
|
||||||
|
if (!remote || !remote.allowInput) return;
|
||||||
|
if (data.role !== 'admin') return;
|
||||||
|
|
||||||
|
const bounds = mainWindow.getContentBounds();
|
||||||
|
const x = Math.round((data.x || 0) * bounds.width);
|
||||||
|
const y = Math.round((data.y || 0) * bounds.height);
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'mousemove':
|
||||||
|
mainWindow.webContents.sendInputEvent({ type: 'mouseMove', x, y });
|
||||||
|
break;
|
||||||
|
case 'mousedown':
|
||||||
|
mainWindow.webContents.sendInputEvent({
|
||||||
|
type: 'mouseDown', x, y,
|
||||||
|
button: data.button === 'right' ? 'right' : 'left',
|
||||||
|
clickCount: 1
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'mouseup':
|
||||||
|
mainWindow.webContents.sendInputEvent({
|
||||||
|
type: 'mouseUp', x, y,
|
||||||
|
button: data.button === 'right' ? 'right' : 'left',
|
||||||
|
clickCount: 1
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'scroll':
|
||||||
|
mainWindow.webContents.sendInputEvent({
|
||||||
|
type: 'mouseWheel', x, y,
|
||||||
|
deltaX: data.deltaX || 0,
|
||||||
|
deltaY: data.deltaY || 0
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'keydown':
|
||||||
|
mainWindow.webContents.sendInputEvent({
|
||||||
|
type: 'keyDown',
|
||||||
|
keyCode: data.key,
|
||||||
|
modifiers: buildModifiers(data)
|
||||||
|
});
|
||||||
|
if (data.key.length === 1) {
|
||||||
|
mainWindow.webContents.sendInputEvent({
|
||||||
|
type: 'char',
|
||||||
|
keyCode: data.key,
|
||||||
|
modifiers: buildModifiers(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'keyup':
|
||||||
|
mainWindow.webContents.sendInputEvent({
|
||||||
|
type: 'keyUp',
|
||||||
|
keyCode: data.key,
|
||||||
|
modifiers: buildModifiers(data)
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildModifiers(data) {
|
||||||
|
const mods = [];
|
||||||
|
if (data.shift) mods.push('shift');
|
||||||
|
if (data.ctrl) mods.push('control');
|
||||||
|
if (data.alt) mods.push('alt');
|
||||||
|
return mods;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC: Get capture source ID (desktopCapturer must run in main process in Electron 33+)
|
||||||
|
ipcMain.handle('remote:get-capture-source-id', async () => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return null;
|
||||||
|
const { desktopCapturer } = require('electron');
|
||||||
|
const sources = await desktopCapturer.getSources({ types: ['window'] });
|
||||||
|
const title = mainWindow.getTitle();
|
||||||
|
const source = sources.find(s => s.name === title);
|
||||||
|
return source ? source.id : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// IPC: Client count updates from capture window
|
||||||
|
ipcMain.on('remote:client-count', (_event, count) => {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send('remote:client-count', count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// IPC: Remote settings
|
||||||
|
ipcMain.handle('remote:get-settings', () => {
|
||||||
|
const config = configStore.load();
|
||||||
|
return config.globalSettings && config.globalSettings.remote || {};
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote:save-settings', async (_event, remoteSettings) => {
|
||||||
|
const config = configStore.load();
|
||||||
|
const gs = { ...config.globalSettings, remote: remoteSettings };
|
||||||
|
await configStore.save({ globalSettings: gs });
|
||||||
|
|
||||||
|
if (remoteSettings.enabled) {
|
||||||
|
await startRemoteServer();
|
||||||
|
} else if (remoteServer) {
|
||||||
|
remoteServer.stop();
|
||||||
|
remoteServer = null;
|
||||||
|
destroyCaptureWindow();
|
||||||
|
debugLog('remote-server stopped');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote:generate-token', () => {
|
||||||
|
return generateToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('remote:status', () => {
|
||||||
|
return {
|
||||||
|
running: !!remoteServer,
|
||||||
|
port: remoteServer ? remoteServer.getPort() : null,
|
||||||
|
clientCount: remoteServer ? remoteServer.getClientCount() : 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// --- Always on top ---
|
// --- Always on top ---
|
||||||
ipcMain.handle('set-always-on-top', async (_event, value) => {
|
ipcMain.handle('set-always-on-top', async (_event, value) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
|||||||
28
package-lock.json
generated
28
package-lock.json
generated
@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "1.8.6",
|
"version": "2.0.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "1.8.6",
|
"version": "2.0.6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"undici": "^7.16.0"
|
"undici": "^7.16.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
@ -5433,6 +5434,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xmlbuilder": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "2.0.6",
|
"version": "2.1.0",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -12,7 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"undici": "^7.16.0"
|
"undici": "^7.16.0",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
|
|||||||
10
preload.js
10
preload.js
@ -93,6 +93,15 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
onShutdownCountdown: (callback) => {
|
onShutdownCountdown: (callback) => {
|
||||||
ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data));
|
ipcRenderer.on('shutdown-countdown', (_event, data) => callback(data));
|
||||||
},
|
},
|
||||||
|
// Remote Control
|
||||||
|
remoteGetSettings: () => ipcRenderer.invoke('remote:get-settings'),
|
||||||
|
remoteSaveSettings: (settings) => ipcRenderer.invoke('remote:save-settings', settings),
|
||||||
|
remoteGenerateToken: () => ipcRenderer.invoke('remote:generate-token'),
|
||||||
|
remoteStatus: () => ipcRenderer.invoke('remote:status'),
|
||||||
|
onRemoteClientCount: (callback) => {
|
||||||
|
ipcRenderer.on('remote:client-count', (_event, count) => callback(count));
|
||||||
|
},
|
||||||
|
|
||||||
// File path from drag & drop (Electron 33+ compatible)
|
// File path from drag & drop (Electron 33+ compatible)
|
||||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||||
removeAllListeners: () => {
|
removeAllListeners: () => {
|
||||||
@ -105,5 +114,6 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
||||||
ipcRenderer.removeAllListeners('drop-target:files');
|
ipcRenderer.removeAllListeners('drop-target:files');
|
||||||
ipcRenderer.removeAllListeners('account-switched');
|
ipcRenderer.removeAllListeners('account-switched');
|
||||||
|
ipcRenderer.removeAllListeners('remote:client-count');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
122
renderer/app.js
122
renderer/app.js
@ -2023,6 +2023,97 @@ function renderSettings() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Remote Control Panel ---
|
||||||
|
const remoteSettings = globalSettings.remote || {};
|
||||||
|
const remotePanel = document.createElement('div');
|
||||||
|
remotePanel.className = 'hoster-settings-panel';
|
||||||
|
remotePanel.innerHTML = `
|
||||||
|
<div class="hoster-panel-header" data-hoster="remote">
|
||||||
|
<span class="panel-arrow">▶</span>
|
||||||
|
<span class="panel-title">Fernsteuerung</span>
|
||||||
|
<span class="panel-status${remoteSettings.enabled ? ' active' : ''}" id="remoteStatusBadge">${remoteSettings.enabled ? 'Aktiv' : 'Inaktiv'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hoster-panel-body" data-panel="remote" style="display:none">
|
||||||
|
<div class="settings-section-label">Server</div>
|
||||||
|
<div class="settings-grid-mini">
|
||||||
|
<div class="settings-row checkbox-row">
|
||||||
|
<label>Aktiviert</label>
|
||||||
|
<input type="checkbox" class="settings-autosave" id="remoteEnabledInput" ${remoteSettings.enabled ? 'checked' : ''}>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row checkbox-row">
|
||||||
|
<label>Input erlauben</label>
|
||||||
|
<input type="checkbox" class="settings-autosave" id="remoteAllowInputInput" ${remoteSettings.allowInput !== false ? 'checked' : ''}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<label>Port</label>
|
||||||
|
<input type="number" class="hs-input settings-autosave" id="remotePortInput" value="${remoteSettings.port || 9100}" min="1024" max="65535" style="width:100px">
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<label>API-Token</label>
|
||||||
|
<input type="text" class="key-input" id="remoteTokenInput" value="${remoteSettings.token || ''}" readonly style="flex:1">
|
||||||
|
<button class="btn btn-xs btn-secondary" id="remoteCopyTokenBtn" title="Kopieren">Kopieren</button>
|
||||||
|
<button class="btn btn-xs btn-secondary" id="remoteRegenerateTokenBtn" title="Neu generieren">Neu</button>
|
||||||
|
</div>
|
||||||
|
<div class="settings-section-label">Status</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span id="remoteConnectionStatus" style="color:#94a3b8">Prüfe...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(remotePanel);
|
||||||
|
|
||||||
|
// Toggle remote panel
|
||||||
|
remotePanel.querySelector('.hoster-panel-header').addEventListener('click', () => {
|
||||||
|
const body = remotePanel.querySelector('.hoster-panel-body');
|
||||||
|
const arrow = remotePanel.querySelector('.panel-arrow');
|
||||||
|
const isOpen = body.style.display !== 'none';
|
||||||
|
body.style.display = isOpen ? 'none' : 'block';
|
||||||
|
arrow.innerHTML = isOpen ? '▶' : '▼';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy token
|
||||||
|
document.getElementById('remoteCopyTokenBtn').addEventListener('click', async () => {
|
||||||
|
const token = document.getElementById('remoteTokenInput').value;
|
||||||
|
if (token) {
|
||||||
|
await window.api.copyToClipboard(token);
|
||||||
|
document.getElementById('remoteCopyTokenBtn').textContent = 'Kopiert!';
|
||||||
|
setTimeout(() => { document.getElementById('remoteCopyTokenBtn').textContent = 'Kopieren'; }, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regenerate token
|
||||||
|
document.getElementById('remoteRegenerateTokenBtn').addEventListener('click', async () => {
|
||||||
|
const newToken = await window.api.remoteGenerateToken();
|
||||||
|
document.getElementById('remoteTokenInput').value = newToken;
|
||||||
|
scheduleSettingsSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update status
|
||||||
|
window.api.remoteStatus().then(status => {
|
||||||
|
const el = document.getElementById('remoteConnectionStatus');
|
||||||
|
if (!el) return;
|
||||||
|
if (status.running) {
|
||||||
|
el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`;
|
||||||
|
el.style.color = '#10b981';
|
||||||
|
} else {
|
||||||
|
el.textContent = 'Nicht aktiv';
|
||||||
|
el.style.color = '#94a3b8';
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Live client count updates
|
||||||
|
window.api.onRemoteClientCount((count) => {
|
||||||
|
const el = document.getElementById('remoteConnectionStatus');
|
||||||
|
if (el && el.style.color === 'rgb(16, 185, 129)') {
|
||||||
|
window.api.remoteStatus().then(status => {
|
||||||
|
if (status.running) {
|
||||||
|
el.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`;
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Backup Panel ---
|
// --- Backup Panel ---
|
||||||
const backupPanel = document.createElement('div');
|
const backupPanel = document.createElement('div');
|
||||||
backupPanel.className = 'hoster-settings-panel';
|
backupPanel.className = 'hoster-settings-panel';
|
||||||
@ -2190,6 +2281,12 @@ async function saveSettings(options = {}) {
|
|||||||
delaySec: Math.max(1, parseInt(document.getElementById('fmDelaySecInput')?.value || '3', 10) || 3),
|
delaySec: Math.max(1, parseInt(document.getElementById('fmDelaySecInput')?.value || '3', 10) || 3),
|
||||||
autoStart: !!document.getElementById('fmAutoStartInput')?.checked,
|
autoStart: !!document.getElementById('fmAutoStartInput')?.checked,
|
||||||
hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster)
|
hosters: Array.from(document.querySelectorAll('.fm-hoster-checkbox:checked')).map(el => el.dataset.fmHoster)
|
||||||
|
},
|
||||||
|
remote: {
|
||||||
|
enabled: !!document.getElementById('remoteEnabledInput')?.checked,
|
||||||
|
port: Math.max(1024, Math.min(65535, parseInt(document.getElementById('remotePortInput')?.value || '9100', 10) || 9100)),
|
||||||
|
token: (document.getElementById('remoteTokenInput')?.value || '').trim(),
|
||||||
|
allowInput: !!document.getElementById('remoteAllowInputInput')?.checked
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2241,6 +2338,31 @@ async function saveSettings(options = {}) {
|
|||||||
if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; }
|
if (badge) { badge.textContent = 'Inaktiv'; badge.className = 'panel-status'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start/stop remote server based on settings
|
||||||
|
const remoteSettings = globalSettings.remote;
|
||||||
|
const remoteBadge = document.getElementById('remoteStatusBadge');
|
||||||
|
if (remoteSettings) {
|
||||||
|
try {
|
||||||
|
await window.api.remoteSaveSettings(remoteSettings);
|
||||||
|
if (remoteBadge) {
|
||||||
|
remoteBadge.textContent = remoteSettings.enabled ? 'Aktiv' : 'Inaktiv';
|
||||||
|
remoteBadge.className = `panel-status${remoteSettings.enabled ? ' active' : ''}`;
|
||||||
|
}
|
||||||
|
// Update status display
|
||||||
|
const status = await window.api.remoteStatus();
|
||||||
|
const statusEl = document.getElementById('remoteConnectionStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
if (status.running) {
|
||||||
|
statusEl.textContent = `Aktiv auf Port ${status.port} — ${status.clientCount} Client(s) verbunden`;
|
||||||
|
statusEl.style.color = '#10b981';
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'Nicht aktiv';
|
||||||
|
statusEl.style.color = '#94a3b8';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const feedback = document.getElementById('saveFeedback');
|
const feedback = document.getElementById('saveFeedback');
|
||||||
feedback.textContent = feedbackText;
|
feedback.textContent = feedbackText;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
53
tests/remote-config.test.js
Normal file
53
tests/remote-config.test.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
const { describe, it } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Minimal app mock for ConfigStore
|
||||||
|
function createTestConfigStore() {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-test-'));
|
||||||
|
const mockApp = {
|
||||||
|
isPackaged: false,
|
||||||
|
getPath: (name) => tmpDir,
|
||||||
|
getPath: () => tmpDir
|
||||||
|
};
|
||||||
|
const ConfigStore = require('../lib/config-store');
|
||||||
|
const store = new ConfigStore(mockApp);
|
||||||
|
store.filePath = path.join(tmpDir, 'test-config.json');
|
||||||
|
return { store, tmpDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('remote config defaults', () => {
|
||||||
|
it('should include remote settings in defaults', () => {
|
||||||
|
const { store } = createTestConfigStore();
|
||||||
|
const config = store.load();
|
||||||
|
const remote = config.globalSettings.remote;
|
||||||
|
|
||||||
|
assert.strictEqual(remote.enabled, false);
|
||||||
|
assert.strictEqual(remote.port, 9100);
|
||||||
|
assert.strictEqual(typeof remote.token, 'string');
|
||||||
|
assert.strictEqual(remote.token, '');
|
||||||
|
assert.strictEqual(remote.allowInput, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deep-merge remote settings with existing config', async () => {
|
||||||
|
const { store } = createTestConfigStore();
|
||||||
|
// Save config with partial remote settings
|
||||||
|
await store.save({
|
||||||
|
globalSettings: {
|
||||||
|
remote: { enabled: true, port: 9200 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = store.load();
|
||||||
|
const remote = config.globalSettings.remote;
|
||||||
|
|
||||||
|
// Saved values preserved
|
||||||
|
assert.strictEqual(remote.enabled, true);
|
||||||
|
assert.strictEqual(remote.port, 9200);
|
||||||
|
// Defaults merged in
|
||||||
|
assert.strictEqual(remote.allowInput, true);
|
||||||
|
assert.strictEqual(remote.token, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
41
tests/remote-server.test.js
Normal file
41
tests/remote-server.test.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
// Test the module can be required and has the expected API
|
||||||
|
describe('RemoteServer', () => {
|
||||||
|
it('should export a class with start/stop methods', () => {
|
||||||
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
assert.strictEqual(typeof RemoteServer, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.start, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.stop, 'function');
|
||||||
|
assert.strictEqual(typeof RemoteServer.prototype.getClientCount, 'function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start and stop without errors', async () => {
|
||||||
|
const RemoteServer = require('../lib/remote-server');
|
||||||
|
const server = new RemoteServer();
|
||||||
|
|
||||||
|
// Mock mainWindow
|
||||||
|
const mockMainWindow = {
|
||||||
|
isDestroyed: () => false,
|
||||||
|
getTitle: () => 'Test Window',
|
||||||
|
getContentBounds: () => ({ x: 0, y: 0, width: 1920, height: 1080 }),
|
||||||
|
webContents: {
|
||||||
|
sendInputEvent: () => {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.start({
|
||||||
|
port: 0, // random available port
|
||||||
|
token: 'test-token-123',
|
||||||
|
allowInput: true,
|
||||||
|
mainWindow: mockMainWindow,
|
||||||
|
onSignalingToCapture: () => {},
|
||||||
|
onCreateCaptureWindow: () => {},
|
||||||
|
onDestroyCaptureWindow: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(server.getClientCount(), 0);
|
||||||
|
server.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user