pc-live/PLUGINS.md
Bjoern Flessing be502d2834 IC-Computer: Schreibtisch, Fenster und Apps
Ein Computer im Spiel. Man tritt an ein Terminal, drueckt E, und bekommt eine
Oberflaeche mit Mail, Browser, Adressbuch, Kalender und weiteren Anwendungen.
Andere Resources haengen sich als App ein.

Auf ESX umgestellt:

  - Fuenf harte Abhaengigkeiten zeigten auf Resources, die es hier nicht gibt.
    Eine fehlende Abhaengigkeit verhindert den Start vollstaendig - die
    Resource waere nie hochgekommen.
  - Die Bruecke zum Framework neu geschrieben; Charakterdaten, Aktenverwaltung
    und Immobilien ausgebaut, weil die zugehoerigen Systeme fehlen.
  - pc_live_devices fehlte im Schema, wird vom Code aber gelesen. Ohne die
    Tabelle ist kein fester PC ansprechbar. Ein PC braucht ausserdem zwei
    Eintraege mit derselben Kennung: Standort in der Datenbank, Kamerafahrt in
    der config.lua.

Mail ueberarbeitet:

  - Postfaecher haengen an einer Anmeldung statt an der Person. Mehrere Leute
    koennen dasselbe Firmenpostfach gleichzeitig offen haben.
  - Ordner gehoeren zum Postfach, nicht zur Person. Eine Mail an ein geteiltes
    Postfach wird je Empfaenger einmal gespeichert; damit das Einsortieren
    trotzdem fuer alle gilt, tragen alle Kopien einer Zustellung dieselbe
    Kennung. Gelesen und geloescht bleibt persoenlich - das ist keine
    Eigenschaft der Nachricht, sondern der Person.
  - Signaturen gehoeren ebenfalls zum Postfach.
  - Kalender koennen privat, geteilt oder oeffentlich sein. Ein oeffentlicher
    Termin haengt bewusst auch an einem Postfach, sonst koennte ihn spaeter
    niemand mehr aendern oder absagen.

Neue Apps: Webhosting, Bleeter und dessen Verwaltung.

Behoben:

  - prompt(), confirm() und alert() froren das NUI ein. FiveMs CEF hat keinen
    Handler fuer die eingebauten Browserdialoge - der Aufruf oeffnet nichts und
    kehrt nie zurueck. Ersetzt durch eigene Dialoge.
  - Die Fenster tragen data-wid, keine id. Wer sie mit getElementById sucht,
    schreibt ins Leere und das Fenster bleibt leer.

Enthaelt README.md mit Einrichtung und PLUGINS.md: eine Anleitung, wie man eine
eigene App baut und einhaengt, mit vollstaendigem Beispiel von der Tabelle bis
zum Schreibtischsymbol.
2026-08-09 12:02:11 +00:00

11 KiB
Raw Blame History

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:

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:

-- 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 Netzwerkereignis funktioniert nicht wie gedacht — source ist dort nicht der Spieler, der Handler bricht ab. Baue stattdessen eine Funktion, die beide Wege benutzen:

local function sendeNotizen(src, identifier)  end

Schritt 3 — Client als Brücke

client/notizen.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:

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: '<div class="app-content" id="notizen-main"></div>',
    });
    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

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:

<script src="js/apps/notizen.js"></script>

nui/js/desktop.js — Symbol und Antwortkanal:

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:

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:

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.