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,
|
||||
autoStart: true,
|
||||
hosters: [] // pre-selected hosters, empty = ask via modal
|
||||
},
|
||||
remote: {
|
||||
enabled: false,
|
||||
port: 9100,
|
||||
token: '',
|
||||
allowInput: true
|
||||
}
|
||||
},
|
||||
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 backupCrypto = require('./lib/backup-crypto');
|
||||
const FolderMonitor = require('./lib/folder-monitor');
|
||||
const RemoteServer = require('./lib/remote-server');
|
||||
|
||||
let mainWindow;
|
||||
let dropTargetWindow = null;
|
||||
@ -18,6 +19,8 @@ let tray = null;
|
||||
const configStore = new ConfigStore(app);
|
||||
let uploadManager = null;
|
||||
let folderMonitor = new FolderMonitor();
|
||||
let remoteServer = null;
|
||||
let captureWindow = null;
|
||||
const HEALTH_CHECK_TIMEOUT = 25000;
|
||||
|
||||
// --- 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}`);
|
||||
}
|
||||
|
||||
// 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
|
||||
try {
|
||||
const dtConfig = configStore.load();
|
||||
@ -517,6 +532,10 @@ app.on('window-all-closed', () => {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
try { folderMonitor.stop(); } catch {}
|
||||
try {
|
||||
if (remoteServer) { remoteServer.stop(); remoteServer = null; }
|
||||
destroyCaptureWindow();
|
||||
} catch {}
|
||||
destroyDropTargetWindow();
|
||||
});
|
||||
|
||||
@ -891,6 +910,204 @@ ipcMain.handle('folder-monitor:select-folder', async () => {
|
||||
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 ---
|
||||
ipcMain.handle('set-always-on-top', async (_event, value) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "1.8.6",
|
||||
"version": "2.0.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "1.8.6",
|
||||
"version": "2.0.6",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"undici": "^7.16.0"
|
||||
"undici": "^7.16.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
@ -5433,6 +5434,27 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "multi-hoster-uploader",
|
||||
"version": "2.0.6",
|
||||
"version": "2.1.0",
|
||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
@ -12,7 +12,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"undici": "^7.16.0"
|
||||
"undici": "^7.16.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
10
preload.js
10
preload.js
@ -93,6 +93,15 @@ contextBridge.exposeInMainWorld('api', {
|
||||
onShutdownCountdown: (callback) => {
|
||||
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)
|
||||
getPathForFile: (file) => webUtils.getPathForFile(file),
|
||||
removeAllListeners: () => {
|
||||
@ -105,5 +114,6 @@ contextBridge.exposeInMainWorld('api', {
|
||||
ipcRenderer.removeAllListeners('folder-monitor:new-files');
|
||||
ipcRenderer.removeAllListeners('drop-target:files');
|
||||
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 ---
|
||||
const backupPanel = document.createElement('div');
|
||||
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),
|
||||
autoStart: !!document.getElementById('fmAutoStartInput')?.checked,
|
||||
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'; }
|
||||
}
|
||||
|
||||
// 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');
|
||||
feedback.textContent = feedbackText;
|
||||
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