# Eigene Apps für pc-live Eine App ist kein eigener Ordner und kein Plugin-Format — sie besteht aus vier Teilen, die an vier Stellen eingetragen werden. Diese Anleitung baut eine vollständige App von Null auf: **Notizen**, mit Datenbank, Server, Oberfläche und Schreibtischsymbol. Am Ende steht ein Gerüst zum Kopieren. --- ## Wie eine App aufgebaut ist ``` NUI (Browser) Client (Lua) Server (Lua) ┌──────────────────┐ ┌────────────────────┐ ┌────────────────────┐ │ nui/js/apps/ │ │ client/notizen.lua │ │ server/notizen.lua │ │ notizen.js │ │ │ │ │ │ │ │ │ │ │ │ fetchNui('op') ──┼─────►│ RegisterNUICallback├───►│ RegisterNetEvent │ │ │ │ │ │ │ │ │ window.message ◄─┼──────┤ SendNUIMessage │◄───┤ TriggerClientEvent │ └──────────────────┘ └────────────────────┘ └────────────────────┘ ``` Das NUI kann **nur** Callbacks der eigenen Resource aufrufen — deshalb der Umweg über den Client. Antworten laufen umgekehrt über `SendNUIMessage` und landen im Nachrichtenverteiler in `nui/js/desktop.js`. --- ## Schritt 1 — Tabelle `sql/notizen.sql`: ```sql CREATE TABLE IF NOT EXISTS `pc_live_notizen` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `identifier` VARCHAR(120) NOT NULL, `titel` VARCHAR(120) NOT NULL DEFAULT '', `inhalt` TEXT NOT NULL, `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_identifier` (`identifier`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` --- ## Schritt 2 — Server `server/notizen.lua`: ```lua -- Notizen. Jede Notiz gehoert genau einer Person. RegisterNetEvent('pc-live:server:getNotizen', function() local src = source local user = ActiveSessions[src] -- wer sitzt am PC? if not user then return end local rows = MySQL.query.await( 'SELECT id, titel, inhalt FROM pc_live_notizen WHERE identifier = ? ORDER BY id DESC', { user.identifier }) or {} TriggerClientEvent('pc-live:client:notizen', src, rows) end) RegisterNetEvent('pc-live:server:saveNotiz', function(data) local src = source local user = ActiveSessions[src] if not user then return end if type(data) ~= 'table' then return end -- Immer beschneiden: was aus dem NUI kommt, hat der Spieler in der Hand. local titel = Utils.Sanitize(tostring(data.titel or '')):sub(1, 120) local inhalt = Utils.Sanitize(tostring(data.inhalt or '')):sub(1, 5000) if titel == '' then return end local id = tonumber(data.id) if id then -- Die Bedingung auf identifier ist die Besitzpruefung: ohne sie -- koennte jemand mit einer fremden Id die Notiz eines anderen aendern. MySQL.update.await( 'UPDATE pc_live_notizen SET titel = ?, inhalt = ? WHERE id = ? AND identifier = ?', { titel, inhalt, id, user.identifier }) else MySQL.insert.await( 'INSERT INTO pc_live_notizen (identifier, titel, inhalt) VALUES (?,?,?)', { user.identifier, titel, inhalt }) end TriggerEvent('pc-live:server:getNotizen') -- geht nicht! siehe unten end) ``` > **Falle:** `TriggerEvent` auf ein *Netzwerk*ereignis funktioniert nicht wie > gedacht — `source` ist dort nicht der Spieler, der Handler bricht ab. Baue > stattdessen eine Funktion, die beide Wege benutzen: > > ```lua > local function sendeNotizen(src, identifier) … end > ``` --- ## Schritt 3 — Client als Brücke `client/notizen.lua`: ```lua -- Die Liste erlaubter Aktionen steht fest. Ohne sie koennte das NUI jedes -- beliebige Serverereignis ausloesen. local OPS = { getNotizen = 'pc-live:server:getNotizen', saveNotiz = 'pc-live:server:saveNotiz', } RegisterNUICallback('notizen', function(data, cb) cb({}) -- sofort bestaetigen local event = OPS[tostring(data.op or '')] if not event then return end TriggerServerEvent(event, data.payload or {}) end) RegisterNetEvent('pc-live:client:notizen', function(rows) SendNUIMessage({ action = 'notizen_data', data = rows or {} }) end) ``` --- ## Schritt 4 — Oberfläche `nui/js/apps/notizen.js`: ```js const NotizenApp = (() => { const WIN_ID = 'app-notizen'; const el = ICWebRender.el; // kleiner Helfer aus webhosting.js let notizen = []; let winEl = null; function open() { const win = WindowManager.create({ id: WIN_ID, title: 'Notizen', icon: '📝', width: 720, height: 520, content: '
', }); if (!win) return; winEl = win; // merken! siehe Falle unten fetchNui('notizen', { op: 'getNotizen' }); render(); } function root(id) { if (!winEl || !winEl.isConnected) { winEl = document.querySelector('[data-wid="' + WIN_ID + '"]'); } return winEl ? winEl.querySelector('#' + id) : null; } function render() { const main = root('notizen-main'); if (!main) return; main.replaceChildren(); notizen.forEach(n => { const karte = el('div', 'wh-row'); // Spielerinhalte immer ueber textContent – nie ueber innerHTML. karte.appendChild(el('div', 'wh-row-title', n.titel)); karte.appendChild(el('div', 'wh-row-sub', n.inhalt)); main.appendChild(karte); }); const neu = el('button', 'icweb-btn-primary', 'Neue Notiz'); neu.addEventListener('click', () => { // Kein prompt() – siehe Fallen. ICWebRender.promptText('Neue Notiz', { placeholder: 'Titel' }, (titel) => { fetchNui('notizen', { op: 'saveNotiz', payload: { titel, inhalt: '' } }); }); }); main.appendChild(neu); } /** Vom Nachrichtenverteiler in desktop.js. */ function onData(rows) { notizen = rows || []; render(); } return { open, onData }; })(); ``` --- ## Schritt 5 — Einhängen **`fxmanifest.lua`** ```lua client_scripts { …, 'client/notizen.lua' } server_scripts { …, 'server/notizen.lua' } files { …, 'nui/js/apps/notizen.js' } ``` **`nui/index.html`** — vor `desktop.js`, und nach `webhosting.js`, falls du `ICWebRender` benutzt: ```html ``` **`nui/js/desktop.js`** — Symbol und Antwortkanal: ```js const APP_META = { … 'pc.notizen': { name: 'Notizen', icon: '📝', open: () => NotizenApp.open() }, }; // im Nachrichtenverteiler: case 'notizen_data': NotizenApp.onData(data); break; ``` **`server/apps.lua`** — damit die App auf dem Schreibtisch landet: ```lua AppRegistry.Register({ app_id = "pc.notizen", name = "Notizen", icon = "📝", version = "1.0.0", category = "utility", permissions = {}, dependencies = {}, default = true, -- true = bei jedem vorinstalliert description = "Notizen schreiben und wiederfinden.", price = 0, -- > 0: muss im Store gekauft werden }) ``` Fertig. Neu starten, PC öffnen — das Symbol ist da. --- ## Aus einer anderen Resource Der Serverteil kann auch woanders liegen. Die Registrierung geht dann über einen Export: ```lua exports['pc-live']:RegisterApp({ app_id = 'meine.app', name = 'Meine App', … }) ``` Die **Oberfläche muss trotzdem in `pc-live/nui/`** liegen: Das NUI lädt nur Dateien der eigenen Resource. Deine Resource liefert also die Serverlogik, und in pc-live liegen Brücke und Oberfläche. So machen es `ic-web` und `bleeter`. Der Client deiner Resource kann direkt mit deinem Server sprechen — pc-live muss nichts weiterreichen. Nur der Weg **ins** NUI führt über pc-live, weil `SendNUIMessage` immer im eigenen Fenster landet. --- ## Fallen, die Zeit kosten **Das Fenster hat keine `id`.** `WindowManager.create` setzt `data-wid`. `document.getElementById(WIN_ID)` findet nichts, deine App schreibt ins Leere und das Fenster bleibt leer. Merke dir das zurückgegebene Element oder suche mit `document.querySelector('[data-wid="…"]')`. **`prompt()`, `confirm()` und `alert()` frieren das NUI ein.** FiveMs CEF hat keinen Handler für die eingebauten Browserdialoge — der Aufruf öffnet nichts und kehrt nie zurück. Nimm `ICWebRender.promptText(titel, optionen, rückruf)` und `ICWebRender.confirmBox(titel, text, rückruf, optionen)`. **Kein `innerHTML` für Spielerinhalte.** Das NUI ist derselbe Kontext wie der PC: Wer dort Markup einschleusen kann, kann auch dessen Callbacks aufrufen. `textContent` und `setAttribute`, immer. **`TINYINT(1)` ist mal `1`, mal `true`.** `oxmysql` wandelt es bei `query`/`single` in einen Boolean, bei `prepare` nicht. `== 1` geht deshalb irgendwann schief — und zwar still. Schreib dir einen Helfer wie `ICWeb.Bool`. **Keine Datenbankarbeit in `onResourceStop`.** Ein `await` wartet dort auf eine Antwort, die niemand mehr entgegennimmt: „Execution of function reference in script host failed". Aufräumen gehört in den Start — das erwischt auch den Absturz, den ein Stop-Handler nie sieht. **`TriggerEvent` auf ein Netzwerkereignis** hat kein `source`. Zieh die Logik in eine Funktion und ruf die von beiden Seiten. **Eigene Stile kapseln.** Bindest du eine fremde CSS-Datei ein, färbt sie womöglich den ganzen PC. Setz deine Regeln unter eine eigene Wurzelklasse — siehe `nui/css/erzeuge-bleeter-css.py`, das genau das mechanisch erledigt. --- ## Was schon da ist Bevor du etwas nachbaust: | | aus | | |---|---|---| | `ICWebRender.el(tag, klasse, text)` | `webhosting.js` | Element bauen | | `ICWebRender.modal(titel, aufbau, breit)` | `webhosting.js` | Dialograhmen | | `ICWebRender.promptText` / `confirmBox` | `webhosting.js` | Eingabe und Rückfrage | | `Desktop.showNotification(text)` | `desktop.js` | kurze Meldung | | `WindowManager.create/close/focus` | `windowManager.js` | Fenster | | `fetchNui(name, daten)` | `desktop.js` | Aufruf an den Client | Für das Aussehen kannst du die vorhandenen Klassen benutzen: `wh-page`, `wh-row`, `wh-field`, `wh-label`, `icweb-input`, `icweb-btn-primary`, `icweb-btn-ghost`, `icweb-btn-danger`, `wh-tag`, `wh-hint`. Dann fügt sich deine App ohne eigenes CSS ein.