207 lines
12 KiB
JavaScript
207 lines
12 KiB
JavaScript
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(); });
|
||
}
|