diff --git a/chrome-erweiterung/README.md b/chrome-erweiterung/README.md index 98ab1af5..8dc178d1 100644 --- a/chrome-erweiterung/README.md +++ b/chrome-erweiterung/README.md @@ -9,6 +9,7 @@ DeskHub in der Browserleiste: **Tickets lesen und beantworten**, Aufgaben, eigen - **Ticket-Ansicht direkt in der Erweiterung:** Verlauf als Chat (Kunde, Mitarbeiter, interne Notizen, Kommentare, Anhänge), **Antworten** (mit Textbausteinen, Signatur-Auswahl und Status nach dem Senden), **interne Notiz**, **Status ändern** (mit Abschlussgrund), **zuweisen** (auch „Mir zuweisen“), **Wiedervorlage**, **Beobachten**, Ticket im Browser öffnen. Strg+Enter sendet. Ein angefangener Text bleibt als Entwurf erhalten, auch wenn sich das Popup schließt. - **Aufgaben:** meine, alle offenen, erledigte; abhaken, neue Aufgabe mit Fälligkeit, Bearbeiter und Ticket, löschen. - **Neu:** Ticket anlegen (Anfrager, Betreff, Beschreibung, Abteilung, Priorität, Bearbeiter, Kunden benachrichtigen). +- **Update-Hinweis:** Alle 6 Stunden fragt die Erweiterung den Server, ob in der Plugin-Webseite eine neuere Version liegt, und zeigt dann eine Benachrichtigung und auf der Startseite einen Hinweis mit Weg zur Download-Seite. - **Mehr:** Konto, Meldungen einzeln ein-/ausschalten, Darstellung (automatisch, hell, dunkel), Team-Übersicht mit Status, Weboberfläche öffnen, Abmelden. - **Seitenleiste:** Das Symbol oben rechts im Popup öffnet DeskHub in der Seitenleiste von Chrome (bleibt offen, volle Höhe). Alternativ als eigenes Fenster. - **Zähler und Benachrichtigungen:** Jede Minute fragt die Erweiterung den Server nach Neuem; die Zahl am Symbol zählt, was seit dem letzten Öffnen dazugekommen ist. diff --git a/chrome-erweiterung/app.js b/chrome-erweiterung/app.js index 542b1683..41a59979 100644 --- a/chrome-erweiterung/app.js +++ b/chrome-erweiterung/app.js @@ -71,7 +71,7 @@ async function navigate(view, p = {}) { const nav = h('nav', { class: 'nav', 'aria-label': 'Hauptmenü' }, TABS.map(([key, label, ic]) => h('button', { class: key === activeTab ? 'on' : '', onclick: () => ctx.go(key), 'aria-label': label }, icon(ic, 20), label))); app.replaceChildren(header, content, nav); - chrome.runtime.sendMessage({ type: 'reset-badge' }).catch(() => {}); + chrome.runtime.sendMessage({ type: 'poll-now' }).catch(() => {}); // Zähler aktualisieren (zum Beispiel nach dem Öffnen eines Tickets) try { await VIEWS[view]?.(body, ctx, p); } catch (e) { body.replaceChildren(h('div', { class: 'msg err', role: 'alert' }, errText(e))); } } diff --git a/chrome-erweiterung/background.js b/chrome-erweiterung/background.js index 1d8feab6..735825bd 100644 --- a/chrome-erweiterung/background.js +++ b/chrome-erweiterung/background.js @@ -1,8 +1,9 @@ // Hintergrunddienst: prüft jede Minute auf Neues, zeigt Zähler und Benachrichtigungen, Rechtsklick-Menü, // Tastenkürzel und die Adressleisten-Suche („dh“ + Leertaste). -import { apiCall, getSession, getSettings, truncate } from './lib/core.js'; +import { apiCall, getSession, getSettings, truncate, checkForExtensionUpdate } from './lib/core.js'; const ALARM = 'deskhub-poll'; +const UPDATE_ALARM = 'deskhub-update'; const MENU = 'deskhub-create'; const APP = 'app.html'; @@ -12,7 +13,22 @@ async function setBadge(count, color = '#0f766e') { } async function ensureAlarm() { - if (!(await chrome.alarms.get(ALARM))) chrome.alarms.create(ALARM, { periodInMinutes: 1, delayInMinutes: 0.1 }); + // Alle 30 Sekunden (Chrome erlaubt das ab Version 120, ältere runden auf eine Minute); eine ältere Uhr mit anderem Takt wird ersetzt + const current = await chrome.alarms.get(ALARM); + if (!current || current.periodInMinutes !== 0.5) chrome.alarms.create(ALARM, { periodInMinutes: 0.5, delayInMinutes: 0.1 }); + if (!(await chrome.alarms.get(UPDATE_ALARM))) chrome.alarms.create(UPDATE_ALARM, { periodInMinutes: 360, delayInMinutes: 1 }); +} + +/** Neue Version der Erweiterung? Einmal pro Version eine Benachrichtigung, der Hinweis steht außerdem auf der Startseite. */ +export async function checkUpdate(force = false) { + const info = await checkForExtensionUpdate({ force }); + if (!info) return null; + const { updateNotified } = await chrome.storage.local.get('updateNotified'); + if (updateNotified !== info.version) { + await chrome.storage.local.set({ updateNotified: info.version }); + chrome.notifications.create('deskhub-update', { type: 'basic', iconUrl: 'icons/icon128.png', title: 'DeskHub-Erweiterung', message: `Version ${info.version} ist verfügbar. Auf der Startseite der Erweiterung steht, wie du sie bekommst.` }); + } + return info; } async function ensureSetup() { await ensureAlarm(); @@ -24,16 +40,39 @@ async function ensureSetup() { ensureAlarm(); chrome.runtime.onInstalled.addListener(ensureSetup); -chrome.runtime.onStartup.addListener(async () => { await ensureSetup(); poll(); }); +chrome.runtime.onStartup.addListener(async () => { await ensureSetup(); poll(); checkUpdate(); }); -/** Fragt den Server nach Neuem seit dem letzten Mal. Merkt sich die Serverzeit als Bezugspunkt (kein Uhrzeit-Abgleich mit dem PC nötig). */ +/** + * Fragt den Server nach dem echten Stand: Der Zähler zeigt, wie viele Tickets jetzt Aufmerksamkeit brauchen (offen, dir zugewiesen oder noch + * niemandem, seit der letzten Kundennachricht nicht geöffnet). Wird ein Ticket geöffnet, beantwortet oder übernommen, sinkt er beim nächsten + * Abruf von selbst, auch wenn das in der Weboberfläche oder im Windows-Programm geschah. Benachrichtigt wird nur für Tickets, die neu dazukommen. + * Ältere Server ohne diese Angabe liefern nur „seit dem letzten Mal“: Dafür bleibt die frühere, aufsummierende Zählung als Rückfall. + */ export async function poll() { const session = await getSession(); if (!session) { await setBadge(0); return { skipped: true }; } - const { lastSeen, badgeCount = 0 } = await chrome.storage.local.get(['lastSeen', 'badgeCount']); + const { lastSeen, badgeCount = 0, knownIds = null } = await chrome.storage.local.get(['lastSeen', 'badgeCount', 'knownIds']); try { const { data } = await apiCall('activity.php', { query: lastSeen ? { since: lastSeen } : {} }); const settings = await getSettings(); + if (data.attention && Array.isArray(data.attention.unassigned) && Array.isArray(data.attention.mine)) { + const unassigned = settings.notifyUnassigned ? data.attention.unassigned : []; + const mine = settings.notifyMine ? data.attention.mine : []; + const known = new Set(knownIds || []); + const freshUnassigned = knownIds === null ? 0 : unassigned.filter((id) => !known.has(id)).length; + const freshMine = knownIds === null ? 0 : mine.filter((id) => !known.has(id)).length; + const ids = [...unassigned, ...mine]; + await chrome.storage.local.set({ lastSeen: data.serverTime, badgeCount: ids.length, knownIds: ids }); + await setBadge(ids.length); + const fresh = freshUnassigned + freshMine; + if (fresh > 0 && settings.notifications) { + const parts = []; + if (freshUnassigned) parts.push(`${freshUnassigned} neu und nicht zugewiesen`); + if (freshMine) parts.push(`${freshMine} Aktualisierung${freshMine === 1 ? '' : 'en'} bei deinen Tickets`); + chrome.notifications.create('deskhub-new', { type: 'basic', iconUrl: 'icons/icon128.png', title: 'DeskHub', message: parts.join(', '), priority: 1 }); + } + return { fresh, total: ids.length }; + } const unassigned = settings.notifyUnassigned ? data.newUnassigned || 0 : 0; const replies = settings.notifyMine ? data.newReplies || 0 : 0; const fresh = unassigned + replies; @@ -58,7 +97,7 @@ export async function poll() { } } -chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === ALARM) poll(); }); +chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === ALARM) poll(); else if (alarm.name === UPDATE_ALARM) checkUpdate(); }); const openWindow = (query = '') => chrome.windows.create({ url: chrome.runtime.getURL(`${APP}?mode=window${query}`), type: 'popup', width: 460, height: 760 }); @@ -98,10 +137,12 @@ chrome.notifications.onClicked.addListener(async (id) => { chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { (async () => { - if (msg?.type === 'reset-badge') { await chrome.storage.local.set({ badgeCount: 0 }); await setBadge(0); sendResponse({ ok: true }); } + // Der Zähler wird nicht mehr beim Öffnen geleert: Er folgt dem Stand auf dem Server. „reset-badge“ (ältere Fenster) fragt deshalb nur neu ab. + if (msg?.type === 'reset-badge') sendResponse(await poll()); else if (msg?.type === 'poll-now') sendResponse(await poll()); + else if (msg?.type === 'check-update') sendResponse({ update: await checkUpdate(true) }); else if (msg?.type === 'presence') { await apiCall('presence.php', { method: 'POST', body: {} }).catch(() => {}); sendResponse({ ok: true }); } - else if (msg?.type === 'session-changed') { await ensureAlarm(); await chrome.storage.local.remove(['lastSeen', 'badgeCount']); await setBadge(0); sendResponse(await poll()); } + else if (msg?.type === 'session-changed') { await ensureAlarm(); await chrome.storage.local.remove(['lastSeen', 'badgeCount', 'knownIds']); await setBadge(0); sendResponse(await poll()); } else sendResponse({ ok: false }); })().catch((e) => { try { sendResponse({ error: (e && e.code) || 'ERROR' }); } catch { /* Fenster schon zu */ } }); return true; // Antwort kommt asynchron diff --git a/chrome-erweiterung/lib/core.js b/chrome-erweiterung/lib/core.js index 9064bd7a..578e49c7 100644 --- a/chrome-erweiterung/lib/core.js +++ b/chrome-erweiterung/lib/core.js @@ -134,6 +134,41 @@ export async function openWeb(path) { return chrome.tabs.create({ url: u.href }); } +/** Vergleicht zwei Versionsnummern ("2.0.0" gegen "2.1"): negativ, null oder positiv. Nicht-Zahlen zählen als 0. */ +export function compareVersions(a, b) { + const pa = String(a).split(/[.+-]/).map((x) => parseInt(x, 10) || 0); + const pb = String(b).split(/[.+-]/).map((x) => parseInt(x, 10) || 0); + for (let i = 0; i < Math.max(pa.length, pb.length, 3); i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d !== 0) return d; + } + return 0; +} + +const UPDATE_CHECK_EVERY_MS = 6 * 3600 * 1000; + +/** + * Fragt den Server, ob es eine neuere Version dieser Erweiterung gibt (die Plugin-Webseite liefert sie über DeskHub). Das Ergebnis steht + * in storage.local unter "update": { version, notes, size, checkedAt } oder null, wenn die Erweiterung aktuell ist. Höchstens alle 6 Stunden, + * außer mit force. Netzwerkfehler ändern nichts. + */ +export async function checkForExtensionUpdate({ force = false } = {}) { + const session = await getSession(); + if (!session) return null; + const { update, updateCheckedAt = 0 } = await chrome.storage.local.get(['update', 'updateCheckedAt']); + if (!force && Date.now() - updateCheckedAt < UPDATE_CHECK_EVERY_MS) return update || null; + try { + const { data } = await apiCall('app_update.php', { query: { platform: 'chrome' } }); + const current = chrome.runtime.getManifest().version; + const newer = data?.available && data.version && compareVersions(data.version, current) > 0; + const info = newer ? { version: String(data.version), notes: String(data.notes || ''), size: Number(data.size) || 0, checkedAt: Date.now() } : null; + await chrome.storage.local.set({ update: info, updateCheckedAt: Date.now() }); + return info; + } catch { + return update || null; // Fehler: beim nächsten Mal erneut + } +} + /** Maskiert Text und macht Zeilenumbrüche zu
: Der Server nimmt Ticket-Nachrichten als HTML an und bereinigt sie zusätzlich. */ export function plainToHtml(text) { return String(text ?? '').replace(/&/g, '&').replace(//g, '>').replace(/\r\n|\r|\n/g, '
'); diff --git a/chrome-erweiterung/manifest.json b/chrome-erweiterung/manifest.json index 07874878..3dfaffc0 100644 --- a/chrome-erweiterung/manifest.json +++ b/chrome-erweiterung/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "DeskHub", - "version": "2.0.0", + "version": "2.1.1", "description": "DeskHub in der Browserleiste: Tickets lesen und beantworten, Aufgaben, Status, Benachrichtigungen und schnell ein Ticket anlegen.", "minimum_chrome_version": "116", "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }, diff --git a/chrome-erweiterung/test/core.test.mjs b/chrome-erweiterung/test/core.test.mjs index 74277234..76ef26da 100644 --- a/chrome-erweiterung/test/core.test.mjs +++ b/chrome-erweiterung/test/core.test.mjs @@ -1,289 +1,383 @@ -// Tests des Kerns und des Hintergrunddienstes mit einem nachgebauten Browser (chrome.*) und einem nachgebauten Server (fetch). -// Start: node --test test/ -import test from 'node:test'; -import assert from 'node:assert/strict'; +// Tests des Kerns und des Hintergrunddienstes mit einem nachgebauten Browser (chrome.*) und einem nachgebauten Server (fetch). +// Start: node --test test/ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Nachbau von chrome.* ───────────────────────────────────────────────────────────────────────── +function makeChrome() { + const store = {}; + const calls = { notifications: [], badge: { text: '', color: '' }, tabs: [], permissionsRequested: [], permissionsRemoved: [], menus: [], windows: [] }; + const listener = () => ({ addListener() {} }); + const chromeMock = { + storage: { local: { + async get(keys) { const out = {}; for (const k of [].concat(keys ?? Object.keys(store))) if (k in store) out[k] = structuredClone(store[k]); return out; }, + async set(obj) { for (const [k, v] of Object.entries(obj)) store[k] = structuredClone(v); }, + async remove(keys) { for (const k of [].concat(keys)) delete store[k]; }, + } }, + permissions: { + granted: new Set(), allow: true, + async contains({ origins }) { return origins.every((o) => chromeMock.permissions.granted.has(o)); }, + async request({ origins }) { calls.permissionsRequested.push(...origins); if (chromeMock.permissions.allow) origins.forEach((o) => chromeMock.permissions.granted.add(o)); return chromeMock.permissions.allow; }, + async remove({ origins }) { calls.permissionsRemoved.push(...origins); origins.forEach((o) => chromeMock.permissions.granted.delete(o)); return true; }, + }, + tabs: { async create(o) { calls.tabs.push(o); return o; } }, + windows: { async create(o) { calls.windows.push(o); return o; } }, + action: { + async setBadgeText({ text }) { calls.badge.text = text; }, + async setBadgeBackgroundColor({ color }) { calls.badge.color = color; }, + async openPopup() {}, + }, + notifications: { create(id, o) { calls.notifications.push({ id, ...o }); }, clear() {}, onClicked: listener() }, + alarms: { async get() { return null; }, create() {}, onAlarm: listener() }, + contextMenus: { removeAll(cb) { cb?.(); }, create(o) { calls.menus.push(o); }, onClicked: listener() }, + runtime: { getManifest: () => ({ version: '2.0.0' }), onInstalled: listener(), onStartup: listener(), onMessage: listener(), getURL: (p) => 'chrome-extension://x/' + p, async sendMessage() { return {}; } }, + }; + return { chromeMock, store, calls }; +} + +/** Nachbau des Servers: Antworten werden vorgegeben, alle Aufrufe mitgeschrieben. */ +function makeFetch(routes) { + const log = []; + const fn = async (url, init = {}) => { + const u = new URL(url); const key = `${init.method || 'GET'} ${u.pathname.split('/api/staff/v1/')[1]}`; + log.push({ key, url: u, init }); + const r = routes[key]; + if (!r) return new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'unbekannt' } }), { status: 404 }); + const res = typeof r === 'function' ? await r(u, init) : r; + return new Response(JSON.stringify(res.body ?? null), { status: res.status ?? 200, headers: { 'content-type': 'application/json' } }); + }; + fn.log = log; + return fn; +} + +let env; +async function fresh() { + env = makeChrome(); globalThis.chrome = env.chromeMock; + return await import('../lib/core.js?' + Math.random()); +} +const SERVER = 'https://support.example.de'; +const SESSION = { serverUrl: SERVER, token: 'T'.repeat(48), expiresAt: '2099-01-01 00:00:00', staff: { id: 1, name: 'Lukas', permissions: { create_ticket: true } } }; + +test('Serveradresse: bereinigen und ablehnen', async () => { + const { normalizeServerUrl } = await fresh(); + assert.equal(normalizeServerUrl('support.example.de'), 'https://support.example.de'); + assert.equal(normalizeServerUrl(' https://support.example.de/ '), 'https://support.example.de'); + assert.equal(normalizeServerUrl('https://example.de/helpdesk/'), 'https://example.de/helpdesk'); + assert.equal(normalizeServerUrl('http://127.0.0.1:8080'), 'http://127.0.0.1:8080'); + for (const bad of ['', 'http://support.example.de', 'https://user:pw@example.de', 'https://example.de/?x=1', 'https://example.de/#a', 'ftp://example.de', 'http://localhost.evil.de']) assert.throws(() => normalizeServerUrl(bad), undefined, bad); +}); + +test('apiCall sendet das Token in beiden Headern, keine Cookies, keine Weiterleitung', async () => { + const { apiCall, setSession } = await fresh(); + await setSession(SESSION); + globalThis.fetch = makeFetch({ 'GET tickets.php': { body: { items: [] } } }); + await apiCall('tickets.php', { query: { q: 'drucker', page: 2, leer: '' } }); + const { init, url } = globalThis.fetch.log[0]; + assert.equal(init.headers.Authorization, `Bearer ${SESSION.token}`); + assert.equal(init.headers['X-DeskHub-Authorization'], `Bearer ${SESSION.token}`); + assert.equal(init.credentials, 'omit'); assert.equal(init.redirect, 'error'); + assert.equal(url.searchParams.get('q'), 'drucker'); assert.equal(url.searchParams.get('page'), '2'); assert.equal(url.searchParams.has('leer'), false); +}); + +test('apiCall: 401 räumt die Anmeldung weg, Netzwerkfehler wird gemeldet', async () => { + const { apiCall, setSession, getSession, ApiError } = await fresh(); + await setSession(SESSION); + globalThis.fetch = makeFetch({ 'GET me.php': { status: 401, body: { error: { code: 'UNAUTHENTICATED', message: 'Abgelaufen.' } } } }); + await assert.rejects(apiCall('me.php'), (e) => e instanceof ApiError && e.status === 401 && e.message === 'Abgelaufen.'); + assert.equal(await getSession(), null); + await setSession(SESSION); + globalThis.fetch = async () => { throw new TypeError('fail'); }; + await assert.rejects(apiCall('me.php'), (e) => e.code === 'NETWORK' && e.status === 0); + assert.notEqual(await getSession(), null, 'ein Netzwerkfehler meldet nicht ab'); +}); + +test('Anmeldung: ohne Rückfrage, Authenticator-Code, Speichern', async () => { + const { login, getSession } = await fresh(); + let step = 0; + globalThis.fetch = makeFetch({ 'POST login.php': (u, init) => { + const b = JSON.parse(init.body); + assert.equal(init.headers.Authorization, undefined, 'beim Anmelden gibt es noch kein Token'); + if (++step === 1) { assert.equal(b.totpCode, null); return { status: 202, body: { requiresTotp: true } }; } + assert.equal(b.totpCode, '123456'); + return { body: { token: 'N'.repeat(48), expiresAt: '2099-01-01', staff: { id: 2, name: 'Agent' } } }; + } }); + const r1 = await login('support.example.de', 'a@b.de', 'geheim', ''); + assert.deepEqual(r1, { requiresTotp: true }); + assert.deepEqual(env.calls.permissionsRequested, [], 'es gibt keine Berechtigungsabfrage mehr'); + assert.equal(await getSession(), null, 'ohne Code noch keine Anmeldung'); + const r2 = await login('support.example.de', 'a@b.de', 'geheim', '123456'); + assert.equal(r2.session.staff.name, 'Agent'); + assert.equal((await getSession()).serverUrl, SERVER); +}); + +test('Anmeldung mit falscher Adresse bricht ab, ohne etwas zu senden', async () => { + const { login } = await fresh(); + globalThis.fetch = makeFetch({}); + await assert.rejects(login('http://support.example.de', 'a@b.de', 'x', ''), /HTTPS/); + assert.equal(globalThis.fetch.log.length, 0); +}); + +test('Abmelden sperrt das Token und entfernt die Daten', async () => { + const { logout, setSession, getSession } = await fresh(); + await setSession(SESSION); await chrome.storage.local.set({ badgeCount: 3, lastSeen: 'x' }); + globalThis.fetch = makeFetch({ 'POST logout.php': { body: { ok: true } } }); + await logout(); + assert.equal(globalThis.fetch.log[0].key, 'POST logout.php'); + assert.equal(await getSession(), null); + assert.deepEqual(await chrome.storage.local.get(['badgeCount', 'lastSeen']), {}); +}); + +test('apiBeacon: Anfrage geht sofort und mit keepalive raus (Popup schließt sich)', async () => { + const { apiBeacon } = await fresh(); + globalThis.fetch = makeFetch({ 'POST ticket_unlock.php': { body: { ok: true } } }); + apiBeacon(SESSION, 'ticket_unlock.php', { number: 'T-1' }); + assert.equal(globalThis.fetch.log.length, 1, 'synchron abgeschickt, ohne auf Speicherzugriffe zu warten'); + const { init } = globalThis.fetch.log[0]; + assert.equal(init.keepalive, true); assert.equal(init.method, 'POST'); assert.equal(init.credentials, 'omit'); + assert.equal(init.headers.Authorization, `Bearer ${SESSION.token}`); + assert.deepEqual(JSON.parse(init.body), { number: 'T-1' }); + apiBeacon(null, 'ticket_unlock.php', {}); + assert.equal(globalThis.fetch.log.length, 1, 'ohne Sitzung wird nichts gesendet'); +}); + +test('Abmelden löscht auch nicht gesendete Entwürfe, aber nicht die Einstellungen', async () => { + const { clearSession, setSession, setSettings } = await fresh(); + await setSession(SESSION); await setSettings({ theme: 'dark' }); + await chrome.storage.local.set({ 'draft:T-1': 'halber Text', 'draft:T-2': 'mehr', lastLogin: { serverUrl: SERVER } }); + await clearSession(); + const left = await chrome.storage.local.get(null); + assert.deepEqual(Object.keys(left).sort(), ['lastLogin', 'settings']); +}); + +test('Ticketpfad und Textumwandlung', async () => { + const { ticketPath, plainToHtml } = await fresh(); + assert.equal(ticketPath('T-2026-0001'), '/staff/ticket_view.php?n=T-2026-0001'); + for (const bad of ['', 'a b', '../x', 'x&y=1', '