Files
deskhub/desktop/src/main.js
T
2026-11-04 07:58:10 +00:00

266 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { app, BrowserWindow, ipcMain, safeStorage, Notification, shell, Menu, Tray, nativeImage, nativeTheme } = require('electron');
const path = require('path');
const fs = require('fs');
app.setAppUserModelId('de.deskhub.desktop');
// Windows hat eine eigene Titelleiste und ein .ico; Mac und Linux nutzen den normalen Fensterrahmen und ein PNG
const IS_WIN = process.platform === 'win32', IS_MAC = process.platform === 'darwin', IS_LINUX = process.platform === 'linux';
const ICON = path.join(__dirname, 'assets', IS_WIN ? 'icon.ico' : 'icon.png');
let win = null;
const sessionFile = () => path.join(app.getPath('userData'), 'session.bin');
// ─── Sitzung (Token DPAPI-verschlüsselt über safeStorage) ───────────────────
let session = null; // { serverUrl, token, expiresAt, staff }
function loadSession() {
try {
const raw = fs.readFileSync(sessionFile());
session = JSON.parse(safeStorage.isEncryptionAvailable() ? safeStorage.decryptString(raw) : raw.toString('utf8'));
} catch { session = null; }
}
function saveSession() {
if (!session) { try { fs.unlinkSync(sessionFile()); } catch {} return; }
const json = JSON.stringify(session);
fs.writeFileSync(sessionFile(), safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(json) : json, { mode: 0o600 });
}
function normalizeUrl(input) {
let s = String(input || '').trim();
if (!s) throw new Error('Bitte Server-Adresse eingeben.');
if (!s.includes('://')) s = 'https://' + s;
const u = new URL(s);
if (u.protocol !== 'https:') throw new Error('Die Server-Adresse muss HTTPS verwenden.');
if (u.username || u.search || u.hash) throw new Error('Die Server-Adresse ist ungültig.');
return u.origin + u.pathname.replace(/\/+$/, '');
}
async function callApi({ path: p, method = 'GET', query, body, auth = true, serverUrl }) {
const base = serverUrl || session?.serverUrl;
if (!base) throw { status: 401, code: 'UNAUTHENTICATED', message: 'Nicht angemeldet.' };
const url = new URL(`${base}/api/staff/v1/${p}`);
for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v));
const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache' };
if (auth) {
if (!session?.token) throw { status: 401, code: 'UNAUTHENTICATED', message: 'Nicht angemeldet.' };
headers.Authorization = `Bearer ${session.token}`;
headers['X-DeskHub-Authorization'] = `Bearer ${session.token}`; // Ersatz-Header: viele Hoster entfernen "Authorization"
}
const init = { method, headers };
if (body !== undefined && method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); }
let res;
try { res = await fetch(url, { ...init, signal: AbortSignal.timeout(30000) }); }
catch (e) { throw { status: 0, code: 'NETWORK', message: 'Server nicht erreichbar.' }; }
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch {}
if (!res.ok && res.status !== 202) {
if (res.status === 401 && auth) { session = null; saveSession(); win?.webContents.send('session-expired'); }
throw { status: res.status, code: data?.error?.code || `HTTP_${res.status}`, message: data?.error?.message || `Serverfehler (${res.status}).` };
}
return { status: res.status, data };
}
ipcMain.handle('session:get', () => session ? { serverUrl: session.serverUrl, staff: session.staff } : null);
ipcMain.handle('auth:login', async (_e, { serverUrl, email, password, totpCode }) => {
try {
const base = normalizeUrl(serverUrl);
const r = await callApi({
path: 'login.php', method: 'POST', auth: false, serverUrl: base,
body: { email, password, totpCode: totpCode || null, deviceName: `Windows Desktop (${require('os').hostname()})` },
});
if (r.status === 202 || r.data?.requiresTotp) return { requiresTotp: true };
session = { serverUrl: base, token: r.data.token, expiresAt: r.data.expiresAt, staff: r.data.staff };
saveSession();
return { staff: session.staff };
} catch (e) { return { error: e.message || String(e) }; }
});
ipcMain.handle('auth:logout', async () => {
try { await callApi({ path: 'logout.php', method: 'POST', body: {} }); } catch {}
session = null; saveSession();
return true;
});
ipcMain.handle('api', async (_e, req) => {
if (typeof req?.path !== 'string' || !/^[a-z_]+\.php$/.test(req.path)) return { error: { message: 'Ungültiger Pfad.' } };
try {
const r = await callApi(req);
if (req.path === 'me.php' && r.data?.staff && session) { session.staff = r.data.staff; saveSession(); }
return { data: r.data };
} catch (e) { return { error: { status: e.status, code: e.code, message: e.message || String(e) } }; }
});
// ─── Programm-Update (Version und Setup kommen vom DeskHub-Server, Quelle ist die Plugin-Webseite) ──────────
const verParts = (v) => String(v).split(/[-+]/)[0].split('.').map((n) => parseInt(n, 10) || 0);
function isNewer(remote, local) {
const a = verParts(remote), b = verParts(local);
for (let i = 0; i < Math.max(a.length, b.length); i++) { if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) > (b[i] || 0); }
return false;
}
let pendingUpdate = null;
ipcMain.handle('update:check', async () => {
if (!IS_WIN) return { data: { available: false, packaged: false, current: app.getVersion(), unsupported: true } }; // das veröffentlichte Setup ist nur für Windows
try {
const r = await callApi({ path: 'app_update.php', query: { platform: 'windows' } });
const d = r.data;
if (!d?.available || !d.version) return { data: { available: false, current: app.getVersion() } };
pendingUpdate = d;
return { data: { available: isNewer(d.version, app.getVersion()), version: d.version, notes: d.notes || '', size: d.size || 0, current: app.getVersion(), packaged: app.isPackaged } };
} catch (e) { return { error: { message: e.message || String(e) } }; }
});
ipcMain.handle('update:install', async () => {
if (!IS_WIN) return { error: { message: 'Das Update ist nur für Windows verfügbar.' } };
if (!pendingUpdate || !session) return { error: { message: 'Keine Update-Information vorhanden.' } };
try {
const url = new URL(`${session.serverUrl}/api/staff/v1/app_update.php`);
url.searchParams.set('platform', 'windows'); url.searchParams.set('download', '1');
const res = await fetch(url, { headers: { Authorization: `Bearer ${session.token}`, 'X-DeskHub-Authorization': `Bearer ${session.token}` } });
if (!res.ok) return { error: { message: `Download fehlgeschlagen (${res.status}).` } };
const dir = fs.mkdtempSync(path.join(app.getPath('temp'), 'deskhub-update-'));
const file = path.join(dir, 'DeskHub-Setup.exe');
const out = fs.createWriteStream(file);
const hash = require('crypto').createHash('sha256');
const total = Number(res.headers.get('content-length')) || pendingUpdate.size || 0;
let got = 0;
for await (const chunk of res.body) {
hash.update(chunk); got += chunk.length;
if (!out.write(chunk)) await new Promise((ok) => out.once('drain', ok));
win?.webContents.send('update-progress', total ? Math.min(99, Math.round((got / total) * 100)) : 0);
}
await new Promise((ok, fail) => out.end((err) => (err ? fail(err) : ok())));
if (hash.digest('hex') !== String(pendingUpdate.sha256).toLowerCase()) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} return { error: { message: 'Die heruntergeladene Datei ist beschädigt (Prüfsumme stimmt nicht). Bitte erneut versuchen.' } }; }
require('child_process').spawn(file, [], { detached: true, stdio: 'ignore' }).unref();
setTimeout(() => { quitting = true; app.quit(); }, 500);
return { data: true };
} catch (e) { return { error: { message: e.message || String(e) } }; }
});
// Seite der Weboberfläche im Standardbrowser öffnen: Der Server gibt eine einmalige Anmelde-Adresse (und prüft dabei die Rechte)
ipcMain.handle('web:open', async (_e, { path: p }) => {
if (typeof p !== 'string' || !p.startsWith('/staff/') || p.length > 200) return { error: { message: 'Ungültige Seite.' } };
try {
const r = await callApi({ path: 'web_pages.php', method: 'POST', body: { path: p } });
const u = new URL(String(r.data?.url || ''));
const base = new URL(session.serverUrl);
if (u.protocol !== 'https:' || u.origin !== base.origin || !u.pathname.endsWith('/staff/app_login.php')) return { error: { message: 'Der Server hat eine unerwartete Adresse geliefert.' } };
await shell.openExternal(u.href);
return { data: true };
} catch (e) { return { error: { message: e.message || String(e) } }; }
});
ipcMain.handle('notify', (_e, { title, body }) => {
if (Notification.isSupported()) {
const n = new Notification({ title: String(title).slice(0, 100), body: String(body).slice(0, 200) });
n.on('click', () => showWindow('tickets'));
n.show();
}
});
// ─── Einstellungen (settings.json im Benutzerordner) ────────────────────────
const DEFAULTS = { theme: 'system', autostart: false, closeToTray: true, notifications: true, pollSeconds: 30, startMinimized: false, bounds: null, language: null };
let settings = { ...DEFAULTS };
// Sprache für Tray-Menü und Meldungen (Auswahl im Programm, sonst die von Windows)
const lang = () => (settings.language === 'de' || settings.language === 'en' ? settings.language : ((app.getLocale() || 'de').toLowerCase().startsWith('de') ? 'de' : 'en'));
const L = (de, en) => (lang() === 'en' ? en : de);
const settingsFile = () => path.join(app.getPath('userData'), 'settings.json');
function loadSettings() { try { settings = { ...DEFAULTS, ...JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) }; } catch { settings = { ...DEFAULTS }; } }
function saveSettings() { try { fs.writeFileSync(settingsFile(), JSON.stringify(settings)); } catch {} }
function applyAutostart() {
if (!app.isPackaged) return;
if (IS_LINUX) { // Electron kennt dort kein setLoginItemSettings: ~/.config/autostart/deskhub.desktop (bei AppImage zeigt APPIMAGE auf die Datei)
const file = path.join(app.getPath('home'), '.config', 'autostart', 'deskhub.desktop');
try {
if (settings.autostart) {
const exe = String(process.env.APPIMAGE || process.execPath).replace(/["\r\n]/g, '');
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `[Desktop Entry]\nType=Application\nName=DeskHub\nExec="${exe}" --hidden\nIcon=deskhub\nX-GNOME-Autostart-enabled=true\n`);
} else fs.rmSync(file, { force: true });
} catch { /* ohne Schreibrecht kein Autostart */ }
return;
}
app.setLoginItemSettings({ openAtLogin: !!settings.autostart, args: settings.autostart ? ['--hidden'] : [] });
}
ipcMain.handle('settings:get', () => ({ ...settings, version: app.getVersion(), packaged: app.isPackaged }));
ipcMain.handle('settings:set', (_e, patch) => {
for (const k of Object.keys(DEFAULTS)) if (k in patch && k !== 'bounds') settings[k] = patch[k];
saveSettings(); applyAutostart();
applyNativeTheme();
try { tray?.setContextMenu(trayMenu()); } catch {}
return { ...settings, version: app.getVersion(), packaged: app.isPackaged };
});
// ─── Tray & Zähler ──────────────────────────────────────────────────────────
let tray = null, quitting = false;
function showWindow(page) {
if (!win) return;
win.show(); if (win.isMinimized()) win.restore(); win.focus();
if (page) win.webContents.send('navigate', page);
}
function trayMenu() {
return Menu.buildFromTemplate([
{ label: L('DeskHub öffnen', 'Open DeskHub'), click: () => showWindow() },
{ label: 'Tickets', click: () => showWindow('tickets') },
{ label: L('Neues Ticket', 'New ticket'), click: () => showWindow('newticket') },
{ type: 'separator' },
{ label: L('Beenden', 'Quit'), click: () => { quitting = true; app.quit(); } },
]);
}
function createTray() {
const img = nativeImage.createFromPath(ICON);
tray = new Tray(img.resize({ width: 16, height: 16 }));
tray.setToolTip('DeskHub');
tray.setContextMenu(trayMenu());
tray.on('click', () => showWindow());
}
ipcMain.handle('badge', (_e, { count, dataUrl }) => {
if (!win) return;
try {
win.setOverlayIcon(count > 0 && dataUrl ? nativeImage.createFromDataURL(dataUrl) : null, count > 0 ? `${count} ${L('neu', 'new')}` : '');
tray?.setToolTip(count > 0 ? `DeskHub – ${count} ${L('neu', 'new')}` : 'DeskHub');
} catch {}
});
// Hell/Dunkel: "system" folgt Windows; die Titelleiste (Fensterknöpfe) wird passend zum Design eingefärbt.
const resolvedTheme = () => (settings.theme === 'system' ? (nativeTheme.shouldUseDarkColors ? 'dark' : 'light') : settings.theme);
const overlayFor = (theme) => (theme === 'light'
? { color: '#28405a', symbolColor: '#e9eff6', height: 54 }
: { color: '#0a1119', symbolColor: '#e4ebf3', height: 54 });
function applyNativeTheme() {
nativeTheme.themeSource = ['light', 'dark'].includes(settings.theme) ? settings.theme : 'system';
try { win?.setTitleBarOverlay(overlayFor(resolvedTheme())); win?.setBackgroundColor(resolvedTheme() === 'light' ? '#eef1f5' : '#0d141d'); } catch {}
}
nativeTheme.on('updated', () => { try { win?.setTitleBarOverlay(overlayFor(resolvedTheme())); } catch {} });
function createWindow() {
const b = settings.bounds || {};
win = new BrowserWindow({
width: b.width || 1320, height: b.height || 840, x: b.x, y: b.y, minWidth: 960, minHeight: 620,
show: false, ...(IS_WIN ? { titleBarStyle: 'hidden', titleBarOverlay: overlayFor(resolvedTheme()) } : {}), backgroundColor: resolvedTheme() === 'light' ? '#eef1f5' : '#0d141d', title: 'DeskHub', autoHideMenuBar: true,
icon: ICON,
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false },
});
if (b.maximized) win.maximize();
win.loadFile(path.join(__dirname, 'index.html'));
win.once('ready-to-show', () => { if (!(process.argv.includes('--hidden') && settings.autostart)) win.show(); });
const saveBounds = () => { if (!win || win.isMinimized()) return; settings.bounds = { ...win.getNormalBounds(), maximized: win.isMaximized() }; saveSettings(); };
win.on('resized', saveBounds); win.on('moved', saveBounds); win.on('maximize', saveBounds); win.on('unmaximize', saveBounds);
win.on('close', (e) => { if (!quitting && settings.closeToTray && tray) { e.preventDefault(); win.hide(); } });
win.on('closed', () => { win = null; });
// Externe Links nie im App-Fenster öffnen
win.webContents.setWindowOpenHandler(({ url }) => { if (/^https:/i.test(url)) shell.openExternal(url); return { action: 'deny' }; });
win.webContents.on('will-navigate', (e, url) => { if (!url.startsWith('file://')) e.preventDefault(); });
}
// Vom Setup einmalig aufgerufen: Windows prüft die neue EXE dabei (Defender), der echte Start ist danach schnell.
if (process.argv.includes('--prewarm')) app.exit(0);
const lock = app.requestSingleInstanceLock();
if (!lock) app.quit();
else {
app.on('second-instance', () => showWindow());
app.on('before-quit', () => { quitting = true; });
app.whenReady().then(() => { Menu.setApplicationMenu(IS_MAC ? Menu.buildFromTemplate([{ role: 'appMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]) : null); loadSettings(); loadSession(); createWindow(); setImmediate(() => { createTray(); applyAutostart(); }); });
app.on('window-all-closed', () => { if (quitting || !settings.closeToTray) app.quit(); });
}