diff --git a/desktop/package.json b/desktop/package.json index 52868884..22792a79 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "deskhub", "productName": "DeskHub", - "version": "2.10.2", + "version": "2.11.0", "description": "DeskHub Desktop-Client für Mitarbeiter", "main": "src/main.js", "author": "M_Viper", @@ -10,14 +10,18 @@ "scripts": { "start": "electron .", "test": "electron test/run.js", - "build": "electron-builder --win dir && node scripts/build-setup.js" + "build": "electron-builder --win dir && node scripts/build-setup.js", + "build:linux": "electron-builder --linux", + "build:mac": "electron-builder --mac" }, "build": { "appId": "de.deskhub.desktop", "productName": "DeskHub", "artifactName": "DeskHub.${ext}", "files": ["src/**/*", "package.json", "LICENSE"], - "win": { "target": "dir", "icon": "src/assets/icon.ico" } + "win": { "target": "dir", "icon": "src/assets/icon.ico" }, + "linux": { "target": ["AppImage", "tar.gz"], "icon": "src/assets/icon.png", "extraFiles": [{ "from": "scripts/linux/start-deskhub.sh", "to": "start-deskhub.sh" }], "category": "Office", "artifactName": "DeskHub-${version}-linux-${arch}.${ext}", "synopsis": "DeskHub Desktop-Client für Mitarbeiter" }, + "mac": { "target": ["dmg", "zip"], "icon": "src/assets/icon.png", "category": "public.app-category.business", "artifactName": "DeskHub-${version}-mac-${arch}.${ext}" } }, "devDependencies": { "electron": "^33.2.0", diff --git a/desktop/scripts/linux/start-deskhub.sh b/desktop/scripts/linux/start-deskhub.sh new file mode 100644 index 00000000..5d33d353 --- /dev/null +++ b/desktop/scripts/linux/start-deskhub.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Startet DeskHub. Ein unter Windows gebautes Archiv enthält keine Ausführungsrechte; sie werden hier beim ersten Start gesetzt. +# Aufruf (auch ohne Ausführungsrecht): sh start-deskhub.sh +cd "$(dirname "$0")" || exit 1 +chmod +x deskhub 2>/dev/null +[ -f chrome-sandbox ] && chmod 755 chrome-sandbox 2>/dev/null +exec ./deskhub "$@" diff --git a/desktop/src/app.js b/desktop/src/app.js index 4d4952a0..897b0572 100644 --- a/desktop/src/app.js +++ b/desktop/src/app.js @@ -1,4 +1,6 @@ 'use strict'; +// Mac und Linux: normaler Fensterrahmen statt eigener Titelleiste (nur Windows hat sie) +if (!/Windows/i.test(navigator.userAgent)) document.documentElement.classList.add('native-frame'); // append() soll null/false/Arrays wie h() behandeln (sonst erscheint der Text "null") const _append = Element.prototype.append; Element.prototype.append = function (...a) { return _append.apply(this, a.flat(Infinity).filter((x) => x != null && x !== false)); }; @@ -393,7 +395,13 @@ async function vTickets(main, preset) { const paintBulk = () => { bulk.classList.toggle('hidden', selected.size === 0); clear(bulk); if (!selected.size) return; + const bulkResolutionOptions = [['fixed', 'Fehler behoben'], ['guidance', 'Anleitung'], ['duplicate', 'Doppelt'], ['not_reproducible', 'Nicht reproduzierbar'], ['no_action', 'Keine Maßnahme'], ['spam', 'Spam'], ['automated', 'Automatisch'], ['other', 'Sonstiges']]; const act = async (patch, label) => { + if (['resolved', 'closed'].includes(patch.status)) { + const code = prompt('Abschlussgrund: ' + bulkResolutionOptions.map(([v,l]) => `${v} = ${l}`).join(' | '), 'fixed'); + if (!bulkResolutionOptions.some(([v]) => v === code)) return; + patch = { ...patch, resolutionCode: code, resolutionNote: prompt('Lösungsnotiz (optional):', '') || '' }; + } let ok = 0; const fails = []; for (const number of [...selected]) { try { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); ok++; } catch (e) { fails.push(`${number}: ${e.message}`); } } toast(`${label}: ${ok} Ticket(s)` + (fails.length ? ` · ${fails.length} Fehler (${fails[0]})` : ''), !!fails.length); @@ -463,11 +471,13 @@ async function vTicket(main, number) { const can = (p) => (t.permissions ? t.permissions[p] === true : (state.staff.role === 'admin' || state.staff.permissions?.[p] === true)); const reload = () => go('ticket', number); const update = (patch, msg) => run(async () => { await api('ticket_update.php', { method: 'PATCH', body: { number, ...patch } }); reload(); }, msg); + const resolutionOptions = [['fixed', 'Fehler behoben'], ['guidance', 'Anleitung'], ['duplicate', 'Doppelt'], ['not_reproducible', 'Nicht reproduzierbar'], ['no_action', 'Keine Maßnahme'], ['spam', 'Spam'], ['automated', 'Automatisch'], ['other', 'Sonstiges']]; + const closeWithReason = (status) => { const code = prompt('Abschlussgrund: ' + resolutionOptions.map(([v,l]) => `${v} = ${l}`).join(' | '), 'fixed'); if (!resolutionOptions.some(([v]) => v === code)) return; const note = prompt('Lösungsnotiz (optional):', '') || ''; update({ status, resolutionCode: code, resolutionNote: note }, status === 'resolved' ? 'Gelöst' : 'Geschlossen'); }; const mkSelect = (opts, val, onchange, disabled) => h('select', { disabled, onchange: (e) => onchange(e.target.value) }, ...opts.map(([v, l]) => h('option', { value: v, selected: String(v) === String(val) }, l))); const mine = t.assignedStaff?.id === state.staff.id; const closed = ['resolved', 'closed'].includes(t.status); - const statusSel = mkSelect(state.meta.statuses.map((s) => [s.value, s.label]), t.status, (v) => update({ status: v }, 'Status geändert'), !can('reply_ticket') && !can('close_ticket')); + const statusSel = mkSelect(state.meta.statuses.map((s) => [s.value, s.label]), t.status, (v) => ['resolved', 'closed'].includes(v) ? closeWithReason(v) : update({ status: v }, 'Status geändert'), !can('reply_ticket') && !can('close_ticket')); const assignSel = mkSelect([['', '— niemand —'], ...state.meta.agents.map((a) => [a.id, a.name])], t.assignedStaff?.id ?? '', (v) => update({ assignedStaffId: v === '' ? null : Number(v) }, 'Zuweisung geändert'), !can('assign_ticket')); // Antwort-Editor mit Entwurf @@ -478,6 +488,8 @@ async function vTicket(main, number) { ta.addEventListener('input', () => ta.value ? store.set(draftKey(mode), ta.value) : store.del(draftKey(mode))); const tabs = h('div', { class: 'tabs' }); const sendStatus = mkSelect([['', 'Status: automatisch'], ...state.meta.statuses.map((s) => [s.value, 'Setzen auf: ' + s.label])], '', () => {}); + const sendResolution = mkSelect([['', 'Abschlussgrund wählen'], ...resolutionOptions], '', () => {}); + const resolutionNote = h('input', { placeholder: 'Lösungsnotiz (optional)' }); let cannedList = []; const canned = h('select', { onchange: (e) => { const c = cannedList.find((x) => String(x.id) === e.target.value); if (c) { ta.value += (ta.value ? '\n' : '') + c.body.replace(//gi, '\n').replace(/<\/p>/gi, '\n').replace(/<[^>]+>/g, '').trim(); ta.dispatchEvent(new Event('input')); ta.focus(); } e.target.value = ''; } }, h('option', { value: '' }, 'Textbaustein…')); // Nur Textbausteine der Abteilung des Tickets und die für alle Abteilungen (ältere Server liefern alle: dann hier filtern) @@ -524,16 +536,20 @@ async function vTicket(main, number) { if (can('reply_ticket')) tabs.append(h('button', { class: 'tab' + (mode === 'reply' ? ' active' : ''), onclick: () => { mode = 'reply'; loadDraft(); paintTabs(); } }, 'Antwort an Kunden')); if (can('post_note')) tabs.append(h('button', { class: 'tab' + (mode === 'note' ? ' active' : ''), onclick: () => { mode = 'note'; loadDraft(); paintTabs(); } }, 'Interne Notiz')); sendStatus.classList.toggle('hidden', mode !== 'reply'); + sendResolution.classList.toggle('hidden', mode !== 'reply' || !['resolved', 'closed'].includes(sendStatus.value)); + resolutionNote.classList.toggle('hidden', mode !== 'reply' || !['resolved', 'closed'].includes(sendStatus.value)); sigBox?.classList.toggle('hidden', mode !== 'reply'); sendBtn.textContent = mode === 'reply' ? 'Antwort senden' : 'Notiz speichern'; ta.classList.toggle('noteedit', mode === 'note'); }; paintTabs(); loadDraft(); + sendStatus.addEventListener('change', () => paintTabs()); const send = async () => { if (!ta.value.trim() || sendBtn.disabled) return; if (mode === 'reply' && !confirm('Antwort jetzt an den Kunden senden?')) return; sendBtn.disabled = true; - const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null, signature: mode === 'reply' && sigs ? sigChoice : undefined }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); + if (mode === 'reply' && ['resolved', 'closed'].includes(sendStatus.value) && !resolutionOptions.some(([v]) => v === sendResolution.value)) { toast('Bitte Abschlussgrund auswählen.', true); sendBtn.disabled = false; return; } + const ok = await run(() => post('ticket_message.php', { number, type: mode, message: textToHtml(ta.value.trim()), setStatus: mode === 'reply' && sendStatus.value ? sendStatus.value : null, signature: mode === 'reply' && sigs ? sigChoice : undefined, resolutionCode: sendResolution.value || null, resolutionNote: resolutionNote.value }), mode === 'reply' ? 'Antwort gesendet' : 'Notiz gespeichert'); sendBtn.disabled = false; if (ok) { store.del(draftKey(mode)); reload(); } }; @@ -561,8 +577,8 @@ async function vTicket(main, number) { const actions = h('div', { class: 'tkactions' }, can('assign_ticket') && !mine ? h('button', { class: 'btn sm', onclick: () => update({ assignedStaffId: state.staff.id }, 'Dir zugewiesen') }, 'Mir zuweisen') : null, - !closed && can('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => update({ status: 'resolved' }, 'Gelöst') }, 'Lösen') : null, - !closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => update({ status: 'closed' }, 'Geschlossen') }, 'Schließen') : null, + !closed && can('close_ticket') ? h('button', { class: 'btn ok sm', onclick: () => closeWithReason('resolved') }, 'Lösen') : null, + !closed && can('close_ticket') ? h('button', { class: 'btn ghost sm', onclick: () => closeWithReason('closed') }, 'Schließen') : null, closed && can('reply_ticket') ? h('button', { class: 'btn sm', onclick: () => update({ status: 'open' }, 'Wieder geöffnet') }, 'Wieder öffnen') : null, t.snoozedUntil ? h('button', { class: 'btn ghost sm', onclick: () => update({ snoozeUntil: null }, 'Wiedervorlage aufgehoben') }, 'Wiedervorlage aufheben') @@ -609,7 +625,7 @@ async function vTicket(main, number) { t.canUnlock ? h('button', { class: 'btn sm', onclick: () => run(async () => { await post('ticket_unlock.php', { number }); reload(); }, 'Sperre aufgehoben') }, 'Sperre aufheben und übernehmen') : null) : null, t.lockedByOtherName ? h('div', { class: 'warnbox' }, `Dieser Chat wird gerade von „${t.lockedByOtherName}“ bearbeitet – nur Ansicht.`) : null, h('div', { class: 'split' }, - h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, sigBox, h('div', { class: 'row' }, canned, sendStatus, h('span', { class: 'grow' }), sendBtn)) : null), + h('div', {}, threadsEl, (can('reply_ticket') || can('post_note')) && !t.lockedByOtherName ? h('div', { class: 'panel composer' }, tabs, ta, sigBox, h('div', { class: 'row' }, canned, sendStatus, sendResolution, resolutionNote, h('span', { class: 'grow' }), sendBtn)) : null), h('div', {}, h('div', { class: 'panel' }, h('div', { class: 'sect first' }, 'Eigenschaften'), h('dl', { class: 'props' }, diff --git a/desktop/src/assets/icon.png b/desktop/src/assets/icon.png new file mode 100644 index 00000000..97685228 Binary files /dev/null and b/desktop/src/assets/icon.png differ diff --git a/desktop/src/main.js b/desktop/src/main.js index 5199866a..8bf06ebe 100644 --- a/desktop/src/main.js +++ b/desktop/src/main.js @@ -3,6 +3,9 @@ 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'); @@ -18,7 +21,7 @@ function loadSession() { function saveSession() { if (!session) { try { fs.unlinkSync(sessionFile()); } catch {} return; } const json = JSON.stringify(session); - fs.writeFileSync(sessionFile(), safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(json) : json); + fs.writeFileSync(sessionFile(), safeStorage.isEncryptionAvailable() ? safeStorage.encryptString(json) : json, { mode: 0o600 }); } function normalizeUrl(input) { @@ -97,6 +100,7 @@ function isNewer(remote, local) { } 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; @@ -106,6 +110,7 @@ ipcMain.handle('update:check', async () => { } 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`); @@ -163,6 +168,17 @@ function loadSettings() { try { settings = { ...DEFAULTS, ...JSON.parse(fs.readF 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 })); @@ -191,7 +207,7 @@ function trayMenu() { ]); } function createTray() { - const img = nativeImage.createFromPath(path.join(__dirname, 'assets', 'icon.ico')); + const img = nativeImage.createFromPath(ICON); tray = new Tray(img.resize({ width: 16, height: 16 })); tray.setToolTip('DeskHub'); tray.setContextMenu(trayMenu()); @@ -220,8 +236,8 @@ 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'), + 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(); @@ -244,6 +260,6 @@ 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.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(); }); } diff --git a/desktop/src/styles.css b/desktop/src/styles.css index ccc89e0f..9c27cc64 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -250,3 +250,6 @@ tr.totalrow td { font-weight: 700; background: var(--panel2); border-top: 2px so .lang-flag.on { opacity: 1; outline: 2px solid var(--accent, #14b8a6); outline-offset: 1px; } .lang-flag img { display: block; width: 24px; height: 16px; object-fit: cover; border-radius: 3px; } .login-lang { text-align: right; margin: -4px 0 8px; } + +/* Mac und Linux: normaler Fensterrahmen, keine eigene Titelleiste und kein Platz für die Windows-Fensterknöpfe */ +html.native-frame #titlebar { display: none; } html.native-frame #app { height: 100%; margin-top: 0; } html.native-frame .topbar { padding-right: 18px; }