Files
deskhub/desktop/src/main.js
T
2026-10-24 20:15:40 +00:00

207 lines
12 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');
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);
}
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) } }; }
});
// 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: 60, 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;
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(path.join(__dirname, 'assets', 'icon.ico'));
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, titleBarStyle: 'hidden', titleBarOverlay: overlayFor(resolvedTheme()), backgroundColor: resolvedTheme() === 'light' ? '#eef1f5' : '#0d141d', title: 'DeskHub', autoHideMenuBar: true,
icon: path.join(__dirname, 'assets', 'icon.ico'),
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(null); loadSettings(); loadSession(); createWindow(); setImmediate(() => { createTray(); applyAutostart(); }); });
app.on('window-all-closed', () => { if (quitting || !settings.closeToTray) app.quit(); });
}