Upload via GUI (74 Dateien)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deskhub",
|
||||
"productName": "DeskHub",
|
||||
"version": "2.11.0",
|
||||
"version": "2.12.2",
|
||||
"description": "DeskHub Desktop-Client für Mitarbeiter",
|
||||
"main": "src/main.js",
|
||||
"author": "M_Viper",
|
||||
|
||||
+49
-7
@@ -40,6 +40,8 @@ async function api(path, opts = {}) {
|
||||
e.code = r.error.code; e.status = r.error.status; e.offline = down; throw e;
|
||||
}
|
||||
if (state.offline) setOffline(false);
|
||||
// Änderungen an Tickets (antworten, Status, Zuweisung): Zähler bald neu holen, damit er sofort stimmt
|
||||
if (/^(ticket_message|ticket_update|ticket_spam|ticket_collaborators)\.php/.test(path) && opts.method === 'POST' && typeof pollSoon === 'function') pollSoon();
|
||||
return r.data;
|
||||
}
|
||||
|
||||
@@ -96,6 +98,16 @@ async function installUpdate() {
|
||||
if (r.error) { state.update = { ...state.update, busy: false }; paintBanners(); toast(r.error.message, true); }
|
||||
}
|
||||
window.deskhub.onUpdateProgress((pct) => { if (state.update?.busy) { state.update.pct = pct; paintBanners(); } });
|
||||
/** E-Mail-Abruf von Hand (wie der Knopf in der Ticketliste der Weboberfläche). */
|
||||
async function fetchMailNow(btn) {
|
||||
const old = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = tr('⏳ Abruf läuft …');
|
||||
try {
|
||||
const r = await post('mail_fetch.php', {});
|
||||
toast(r.message, r.level === 'error');
|
||||
} catch (e) { toast(e.message, true); }
|
||||
finally { btn.disabled = false; btn.textContent = old; }
|
||||
}
|
||||
async function run(fn, okMsg) {
|
||||
try { const r = await fn(); if (okMsg) toast(okMsg); return r; }
|
||||
catch (e) { toast(e.message, true); return undefined; }
|
||||
@@ -172,7 +184,7 @@ const metaKey = (url) => 'meta:' + url;
|
||||
async function startApp(staff, cachedMeta, serverUrl) {
|
||||
state.staff = staff;
|
||||
checkUpdate(); clearInterval(state.updateTimer); state.updateTimer = setInterval(checkUpdate, 6 * 3600 * 1000);
|
||||
state.since = ''; state.badge = 0;
|
||||
state.since = ''; state.badge = 0; state.knownIds = null;
|
||||
if (!state.prefs) state.prefs = await window.deskhub.settings();
|
||||
applyTheme();
|
||||
const fresh = (async () => {
|
||||
@@ -212,6 +224,7 @@ function renderShell() {
|
||||
const top = h('header', { class: 'topbar' },
|
||||
h('div', { class: 'brand' }, h('span', { class: 'logo' }, 'D'), 'DeskHub'),
|
||||
can('create_ticket') ? h('button', { class: 'btn ok', onclick: () => go('newticket') }, 'Neues Ticket') : null,
|
||||
h('button', { class: 'btn ghost sm', title: tr('Ruft neue E-Mails aus den Postfächern ab'), onclick: (e) => fetchMailNow(e.currentTarget) }, tr('📥 E-Mails abrufen')),
|
||||
h('button', { class: 'searchbtn', onclick: openPalette }, h('span', { class: 'nl' }, icon('search'), 'Ticket, Kunde oder Bereich suchen…'), h('kbd', {}, 'Strg K')),
|
||||
h('div', { class: 'spacer' }),
|
||||
window.I18N.switcher(),
|
||||
@@ -244,15 +257,16 @@ function go(page, arg) {
|
||||
main.scrollTop = 0;
|
||||
const view = VIEWS_BY_PAGE()[page];
|
||||
if (!view) return;
|
||||
Promise.resolve(view(main, arg)).catch((e) => main.append(h('div', { class: 'warnbox' }, e.message)));
|
||||
Promise.resolve(view(main, arg)).then(() => { if (page === 'ticket') pollSoon(); }).catch((e) => main.append(h('div', { class: 'warnbox' }, e.message)));
|
||||
}
|
||||
window.addEventListener('focus', () => { if (state.staff) pollActivity(); }); // beim Zurückkehren zum Fenster sofort den aktuellen Stand holen
|
||||
function every(ms, fn) { const id = setInterval(fn, ms); state.timers.push({ id, page: true }); }
|
||||
|
||||
async function doLogout() {
|
||||
clearTimeout(state.pollTimer);
|
||||
await window.deskhub.logout();
|
||||
state.timers.forEach((t) => clearInterval(t.id ?? t)); state.timers = []; state.staff = null;
|
||||
window.deskhub.badge(0, null);
|
||||
window.deskhub.badge(0, null); state.knownIds = null;
|
||||
showLogin();
|
||||
}
|
||||
|
||||
@@ -270,7 +284,8 @@ function paintBadge() {
|
||||
if (b) { b.textContent = state.badge; b.classList.toggle('hidden', !state.badge); }
|
||||
window.deskhub.badge(state.badge, state.badge ? badgeImage(state.badge) : null);
|
||||
}
|
||||
function resetBadge() { state.badge = 0; paintBadge(); }
|
||||
// Der Zähler folgt dem Stand auf dem Server (siehe pollActivity) und wird nicht mehr beim Öffnen der Ticketliste geleert; nach einer Aktion fragen wir bald neu ab
|
||||
function pollSoon() { if (state.staff) schedulePoll(1200); }
|
||||
|
||||
function schedulePoll(delay) {
|
||||
clearTimeout(state.pollTimer);
|
||||
@@ -278,10 +293,28 @@ function schedulePoll(delay) {
|
||||
state.pollTimer = setTimeout(async () => { await pollActivity(); if (state.staff) schedulePoll(seconds * 1000); }, delay ?? seconds * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Zähler zeigt, wie viele Tickets jetzt Aufmerksamkeit brauchen (offen, dir zugewiesen oder noch niemandem, seit der letzten
|
||||
* Kundennachricht nicht geöffnet): Er sinkt von selbst, wenn ein Ticket geöffnet, beantwortet oder übernommen wird, auch anderswo
|
||||
* (Weboberfläche, Chrome-Erweiterung). Benachrichtigt wird nur für Tickets, die neu dazukommen. Ältere Server ohne „attention“ liefern nur
|
||||
* „seit dem letzten Mal“: Dafür bleibt die frühere, aufsummierende Zählung.
|
||||
*/
|
||||
async function pollActivity() {
|
||||
try {
|
||||
const a = await api('activity.php', { query: { since: state.since } });
|
||||
if (state.since && (a.newReplies || a.newUnassigned)) {
|
||||
if (a.attention && Array.isArray(a.attention.unassigned) && Array.isArray(a.attention.mine)) {
|
||||
const known = state.knownIds;
|
||||
const freshU = known ? a.attention.unassigned.filter((id) => !known.has(id)).length : 0;
|
||||
const freshM = known ? a.attention.mine.filter((id) => !known.has(id)).length : 0;
|
||||
state.knownIds = new Set([...a.attention.unassigned, ...a.attention.mine]);
|
||||
if (state.badge !== state.knownIds.size) { state.badge = state.knownIds.size; paintBadge(); }
|
||||
if ((freshU || freshM) && state.prefs?.notifications !== false) {
|
||||
const parts = [];
|
||||
if (freshM) parts.push(`${freshM} Aktualisierung(en) an deinen Tickets`);
|
||||
if (freshU) parts.push(`${freshU} neue(s) nicht zugewiesene(s) Ticket(s)`);
|
||||
window.deskhub.notify('DeskHub', parts.map((p) => tr(p)).join(', '));
|
||||
}
|
||||
} else if (state.since && (a.newReplies || a.newUnassigned)) {
|
||||
state.badge += a.newReplies + a.newUnassigned;
|
||||
if (state.prefs?.notifications !== false) {
|
||||
const parts = [];
|
||||
@@ -346,7 +379,7 @@ const VIEWS = {
|
||||
overdue: ['Überfällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'overdue' }],
|
||||
atrisk: ['Bald fällig', { status: 'open,pending,answered,on_hold', assigned: '', sla: 'at_risk' }],
|
||||
paused: ['Pausiert', { status: 'on_hold', assigned: '', sla: '' }],
|
||||
snoozed: ['💤 Wiedervorlage', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '', snoozed: '1' }],
|
||||
snoozed: ['Wiedervorlage', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '', snoozed: '1' }],
|
||||
following: ['👁 Beobachtet', { status: 'open,pending,answered,on_hold', assigned: '', sla: '', following: '1' }],
|
||||
closed: ['Erledigt', { status: 'resolved,closed', assigned: '', sla: '' }],
|
||||
all: ['Alle', { status: 'open,pending,answered,on_hold,resolved,closed', assigned: '', sla: '' }],
|
||||
@@ -430,7 +463,7 @@ async function vTickets(main, preset) {
|
||||
tabs, viewsBar, h('div', { class: 'toolbar' }, search, h('button', { class: 'btn', onclick: () => { f.q = search.value; f.page = 1; load(); } }, 'Suchen'),
|
||||
sel('departmentId', [['', 'Alle Abteilungen'], ...state.meta.departments.map((d) => [d.id, d.name])]), sel('priorityId', [['', 'Alle Prioritäten'], ...state.meta.priorities.map((p) => [p.id, p.name])])),
|
||||
bulk, list);
|
||||
paintTabs(); paintViews(); resetBadge();
|
||||
paintTabs(); paintViews();
|
||||
await load();
|
||||
every(30000, () => { if (!selected.size && !document.querySelector('.modal-bg')) load(true); });
|
||||
}
|
||||
@@ -569,6 +602,14 @@ async function vTicket(main, number) {
|
||||
}));
|
||||
|
||||
const spamBtn = can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => run(async () => { await post('ticket_spam.php', { number, action: t.isSpam ? 'unspam' : 'spam' }); reload(); }, t.isSpam ? 'Kein Spam' : 'Als Spam markiert') }, t.isSpam ? 'Kein Spam' : 'Spam') : null;
|
||||
// Anhänge, Bilder und Formatierung der Original-E-Mail aus dem Postfach nachladen (nur bei E-Mail-Tickets)
|
||||
const reloadMailBtn = t.source === 'email' && can('reply_ticket') ? h('button', { class: 'btn ghost sm', title: tr('Holt Anhänge, Bilder und die Formatierung der E-Mail aus dem Postfach, soweit sie beim Abruf nicht übernommen wurden'), onclick: () => run(async () => {
|
||||
const r = await post('ticket_mail_reload.php', { number });
|
||||
const n = (r.added || 0) + (r.inlineImages || 0);
|
||||
toast(n > 0 || r.formatting ? tr('Anhänge und Bilder aus der E-Mail nachgeladen.') : tr('Es gab nichts Neues zu laden.'));
|
||||
if (r.failed?.length) toast(r.failed.join(' | '), true);
|
||||
reload();
|
||||
}) }, tr('📎 Anhänge laden')) : null;
|
||||
const others = h('div', { class: 'muted' }, 'Lade…');
|
||||
if (t.requesterEmail) api('customer.php', { query: { email: t.requesterEmail } }).then((r) => {
|
||||
const rest = r.tickets.filter((x) => x.number !== number).slice(0, 6);
|
||||
@@ -586,6 +627,7 @@ async function vTicket(main, number) {
|
||||
h('button', { class: 'btn ghost sm', onclick: () => update({ following: !t.following }, t.following ? 'Du beobachtest das Ticket nicht mehr' : 'Du beobachtest das Ticket jetzt') }, t.following ? 'Nicht mehr beobachten' : '👁 Beobachten'),
|
||||
h('button', { class: 'btn ghost sm', title: 'Im Browser öffnen: zusammenführen, aufteilen, drucken', onclick: () => openWebPage('/staff/ticket_view.php?n=' + encodeURIComponent(t.number)) }, '🌐 Im Browser'),
|
||||
spamBtn,
|
||||
reloadMailBtn,
|
||||
h('button', { class: 'btn ghost sm', title: 'Ticketnummer kopieren', onclick: () => { navigator.clipboard?.writeText(t.number); toast('Nummer kopiert'); } }, 'Nummer kopieren'),
|
||||
h('button', { class: 'btn ghost sm', onclick: reload }, 'Aktualisieren'));
|
||||
const st = statusInfo(t.status);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+265
-265
@@ -1,265 +1,265 @@
|
||||
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: 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;
|
||||
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(); });
|
||||
}
|
||||
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(); });
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const tk = (n, s, st, over) => ({ id: n, number: 'T-' + (1000 + n), subject: s,
|
||||
const tickets = [tk(1, 'Drucker druckt nicht', 'open', true), tk(2, 'Passwort zurücksetzen', 'answered'), tk(3, 'Rechnung fehlt', 'open'), tk(4, 'VPN Verbindung bricht ab', 'open', true)];
|
||||
const routes = {
|
||||
'me.php': () => ({ staff }), 'metadata.php': () => ({ staff, departments: [{ id: 1, name: 'Technik' }], priorities: prio, agents: [{ id: 1, name: 'Max Muster' }, { id: 2, name: 'Eva Beispiel' }], statuses, tags: [], liveChatEnabled: true }),
|
||||
'activity.php': () => ({ newReplies: 0, newUnassigned: 0, serverTime: '2026-10-12 10:00:00', health: window.__health || [] }),
|
||||
'activity.php': () => ({ newReplies: 0, newUnassigned: 0, ...(window.__attention ? { attention: window.__attention } : {}), serverTime: '2026-10-12 10:00:00', health: window.__health || [] }),
|
||||
'dashboard.php': () => ({ myOpen: 3, overdue: 2, unassigned: 1, unanswered: 4, resolvedToday: 5, slaPercent: 92.4, recent: tickets, departments: [{ name: 'Technik', open: 8, overdue: 2 }, { name: 'Vertrieb', open: 3, overdue: 0 }] }),
|
||||
'web_pages.php': () => ({ pages: [{ key: 'settings', label: 'Einstellungen', path: '/staff/settings.php', group: 'Verwaltung' }, { key: 'backup', label: 'Backup', path: '/staff/backup.php', group: 'System' }] }),
|
||||
'assistant.php': (req) => {
|
||||
@@ -22,10 +22,12 @@ const routes = {
|
||||
return { dbReady: true, settings: { enabled: true, mode: 'offline_only', name: '', greeting: '', noAnswer: '', departmentId: 0, priorityId: 0, logDays: 90, synonyms: 'Zugang, Einladung, beitreten' }, defaults: { name: 'Digitaler Assistent', greeting: 'Hallo!', noAnswer: 'Keine Antwort.' }, state: { activeNow: true, liveChatEnabled: false, kbEnabled: true, helpdeskOffline: false }, departments: [{ id: 1, name: 'Technik' }], priorities: [{ id: 1, name: 'Normal' }] };
|
||||
},
|
||||
'tickets.php': () => ({ items: tickets, page: 1, perPage: 30, total: 4, hasMore: false }),
|
||||
'ticket.php': () => ({ ticket: { ...tickets[0], collaborators: window.__collabs || [{ id: 1, email: 'cc@firma.de', name: 'Cc Person' }], followers: [{ id: 2, name: 'Eva Beispiel' }], csat: { rating: 4, comment: 'Schnell und freundlich', sentAt: '2026-10-12 09:00:00', answeredAt: '2026-10-12 09:30:00' }, source: 'web', firstResponseDueAt: null, resolutionDueAt: '2026-10-12 12:00:00', similar: { articles: [{ title: 'Drucker einrichten', url: '/kb_article.php?slug=drucker' }], tickets: [{ number: 'T-77', subject: 'Alter Druckerfall' }] }, lockedByOtherName: null, lockedBy: window.__lockedBy || null, canUnlock: !!window.__lockedBy, customFields: [{ label: 'Titel der Geschichte', type: 'text', value: 'Der kleine Bär' }, { label: 'Nutzerbedingungen', type: 'checkbox', value: 'Ja' }], replySignatures: { default: 'own', own: { available: true, text: 'Viele Grüße\nMax persönlich', html: '<p>Viele Grüße</p><div>Max persönlich</div>', hasImage: false }, department: { available: true, name: 'Technik', text: 'Technik-Team Support', html: '<p>Technik-Team Support</p><img src="https://example.org/logo.png" width="120" onerror="alert(1)" style="width:999px"><a href="javascript:alert(1)">Link</a><scr' + 'ipt>alert(2)</scr' + 'ipt>', hasImage: true } }, threads: [
|
||||
'ticket.php': () => ({ ticket: { ...tickets[0], collaborators: window.__collabs || [{ id: 1, email: 'cc@firma.de', name: 'Cc Person' }], followers: [{ id: 2, name: 'Eva Beispiel' }], csat: { rating: 4, comment: 'Schnell und freundlich', sentAt: '2026-10-12 09:00:00', answeredAt: '2026-10-12 09:30:00' }, source: window.__source || 'web', firstResponseDueAt: null, resolutionDueAt: '2026-10-12 12:00:00', similar: { articles: [{ title: 'Drucker einrichten', url: '/kb_article.php?slug=drucker' }], tickets: [{ number: 'T-77', subject: 'Alter Druckerfall' }] }, lockedByOtherName: null, lockedBy: window.__lockedBy || null, canUnlock: !!window.__lockedBy, customFields: [{ label: 'Titel der Geschichte', type: 'text', value: 'Der kleine Bär' }, { label: 'Nutzerbedingungen', type: 'checkbox', value: 'Ja' }], replySignatures: { default: 'own', own: { available: true, text: 'Viele Grüße\nMax persönlich', html: '<p>Viele Grüße</p><div>Max persönlich</div>', hasImage: false }, department: { available: true, name: 'Technik', text: 'Technik-Team Support', html: '<p>Technik-Team Support</p><img src="https://example.org/logo.png" width="120" onerror="alert(1)" style="width:999px"><a href="javascript:alert(1)">Link</a><scr' + 'ipt>alert(2)</scr' + 'ipt>', hasImage: true } }, threads: [
|
||||
{ id: 1, authorType: 'user', authorName: 'Kunde 1', message: 'Mein Drucker druckt nichts mehr.\nBitte helft.', isInternalNote: false, createdAt: '2026-10-12 08:00:00', attachments: [{ id: 1, name: 'foto.png', size: 20480 }], comments: [] },
|
||||
{ id: 2, authorType: 'staff', authorName: 'Max Muster', message: 'Wir schauen uns das an.', isInternalNote: false, createdAt: '2026-10-12 08:30:00', attachments: [], comments: [] },
|
||||
{ id: 3, authorType: 'staff', authorName: 'Max Muster', message: 'Treiber prüfen', isInternalNote: true, createdAt: '2026-10-12 08:35:00', attachments: [], comments: [] }] } }),
|
||||
'mail_fetch.php': (req) => { (window.__mailFetchPosts = window.__mailFetchPosts || []).push(req.body); return { ok: true, level: 'success', message: 'Abruf fertig: 2 E-Mail(s) verarbeitet.', processed: 2, newTickets: 2, replies: 0, errors: [], available: true }; },
|
||||
'ticket_mail_reload.php': (req) => { (window.__mailReloadPosts = window.__mailReloadPosts || []).push(req.body); return { added: 1, inlineImages: 1, formatting: true, skipped: 0, alreadyThere: 0, failed: [] }; },
|
||||
'ticket_collaborators.php': (req) => {
|
||||
window.__collabs = window.__collabs || [{ id: 1, email: 'cc@firma.de', name: 'Cc Person' }];
|
||||
const b = req.body || {};
|
||||
|
||||
@@ -50,6 +50,17 @@ app.whenReady().then(async () => {
|
||||
check('Reiter „Wiedervorlage“, „Pausiert“ und „Beobachtet“ sind da', await run(`(() => { const t = [...document.querySelectorAll('#main .tabs button')].map((b) => b.innerText).join('|'); return t.includes('Wiedervorlage') && t.includes('Pausiert') && t.includes('Beobachtet'); })()`));
|
||||
check('Gespeicherte Ansicht erscheint als Chip', await run(`document.getElementById('main').innerText.includes('Meine überfälligen')`));
|
||||
|
||||
// E-Mail-Abruf von Hand und Nachladen der Mail-Anhänge
|
||||
check('Kopfleiste hat den Knopf „E-Mails abrufen“', await run(`!![...document.querySelectorAll('.topbar button')].find(b => b.textContent.includes('E-Mails abrufen'))`));
|
||||
await run(`[...document.querySelectorAll('.topbar button')].find(b => b.textContent.includes('E-Mails abrufen')).click()`);
|
||||
check('Abruf von Hand sendet die Anfrage und zeigt das Ergebnis', await waitFor(`(window.__mailFetchPosts || []).length === 1 && document.body.innerText.includes('Abruf fertig: 2 E-Mail(s) verarbeitet.')`));
|
||||
check('Knopf ist nach dem Abruf wieder bedienbar', await waitFor(`(() => { const b = [...document.querySelectorAll('.topbar button')].find(b => b.textContent.includes('E-Mails abrufen')); return !!b && !b.disabled; })()`));
|
||||
await run(`window.__source = 'email'; go('ticket', 1)`);
|
||||
check('E-Mail-Ticket: Knopf „Anhänge laden“ ist da', await waitFor(`!![...document.querySelectorAll('#main button')].find(b => b.textContent.includes('Anhänge laden'))`));
|
||||
await run(`[...document.querySelectorAll('#main button')].find(b => b.textContent.includes('Anhänge laden')).click()`);
|
||||
check('Nachladen sendet die Ticketnummer', await waitFor(`(window.__mailReloadPosts || []).length === 1 && !!window.__mailReloadPosts[0].number`));
|
||||
await run(`window.__source = 'web'; go('ticket', 1)`);
|
||||
check('Web-Ticket: kein Knopf „Anhänge laden“', await waitFor(`document.getElementById('main').innerText.includes('Drucker druckt nicht') && ![...document.querySelectorAll('#main button')].find(b => b.textContent.includes('Anhänge laden'))`));
|
||||
await run(`go('ticket', 1)`);
|
||||
check('Ticketdetail zeigt Betreff und Verlauf', await waitFor(`['Drucker druckt nicht', 'Mein Drucker druckt nichts mehr', 'Treiber prüfen'].every(x => document.getElementById('main').innerText.includes(x))`));
|
||||
check('Ticketdetail zeigt Eigenschaften und Aktionen', await run(`['eigenschaften', 'schließen'].every(x => document.getElementById('main').innerText.toLowerCase().includes(x))`));
|
||||
@@ -194,6 +205,24 @@ app.whenReady().then(async () => {
|
||||
check('Klick auf die deutsche Flagge speichert die Sprache', await run(`localStorage.getItem('lang') === 'de'`));
|
||||
await run(`localStorage.removeItem('lang')`);
|
||||
|
||||
console.log('Zähler nach echtem Stand');
|
||||
await run(`go('dashboard')`);
|
||||
await run(`window.__notified = []; const on = window.deskhub.notify; window.deskhub.notify = (t, m) => { window.__notified.push(m); return on && on(t, m); }; window.__badgeCalls = []; const ob = window.deskhub.badge; window.deskhub.badge = (c, d) => { window.__badgeCalls.push(c); return ob && ob(c, d); }; window.__attention = { unassigned: [5], mine: [7] }; state.knownIds = null; state.badge = 0;`);
|
||||
await run(`pollActivity()`);
|
||||
check('Zähler zeigt die Zahl der Tickets, die Aufmerksamkeit brauchen', await waitFor(`state.badge === 2 && document.getElementById('badge') && document.getElementById('badge').textContent === '2'`));
|
||||
check('Erster Abruf meldet nichts (nur Bezugspunkt)', await run(`window.__notified.length === 0`));
|
||||
await run(`window.__attention = { unassigned: [], mine: [] }; pollActivity()`);
|
||||
check('Zähler sinkt von selbst auf 0, wenn nichts mehr offen ist', await waitFor(`state.badge === 0 && document.getElementById('badge').classList.contains('hidden')`));
|
||||
await run(`window.__attention = { unassigned: [9], mine: [] }; pollActivity()`);
|
||||
check('Neues Ticket: Zähler steigt und es kommt eine Meldung', await waitFor(`state.badge === 1 && window.__notified.length === 1 && window.__notified[0].includes('nicht zugewiesen')`));
|
||||
await run(`pollActivity()`);
|
||||
await wait(200);
|
||||
check('Gleicher Stand: keine zweite Meldung', await run(`window.__notified.length === 1`));
|
||||
await run(`go('tickets')`);
|
||||
await wait(300);
|
||||
check('Öffnen der Ticketliste leert den Zähler nicht mehr', await run(`state.badge === 1`));
|
||||
await run(`delete window.__attention`);
|
||||
|
||||
check('Keine JavaScript-Fehler in der Konsole', consoleErrors.length === 0, consoleErrors.slice(0, 3).join(' | '));
|
||||
} catch (e) {
|
||||
failures.push('Testlauf abgebrochen: ' + e.message);
|
||||
|
||||
Reference in New Issue
Block a user