Deine eigene App darauf
Ein funktionierendes Beispiel findest du unter example/mic_phone_demo. Kopiere den Ordner, passe die Namen an und du hast eine fertige App.
my_app/ fxmanifest.lua client.lua registers the app server.lua anything that touches money or the database ui/index.html your page ui/icon.svg your icon-- fxmanifest.luafx_version 'cerulean'game 'gta5'
client_script 'client.lua'server_script 'server.lua'
files { -- WITHOUT THIS your page and icon are not served and the frame comes up blank 'ui/index.html', 'ui/icon.svg',}Der files-Block ist das, was Entwickler am häufigsten vergessen. FiveM liefert nur Dateien aus, die eine Ressource dort auflistet; eine fehlende Seite antwortet mit 404 und deine App öffnet sich als weiße Fläche.
local function register() exports.mic_phone:AddApp({ id = 'phonedemo', name = 'Field Notes', icon = ('https://cfx-nui-%s/ui/icon.svg'):format(GetCurrentResourceName()), ui = ('%s/ui/index.html'):format(GetCurrentResourceName()), })end
AddEventHandler('mic_phone:ready', register) -- fires on start, and after every restart of the phoneDas ist die gesamte Vereinbarung, und mehr ist nicht nötig: Nichts wird auf dem Telefon konfiguriert und nichts wird neu gestartet. Starte deine Ressource und die App befindet sich auf dem Startbildschirm — default = true platziert sie dort, default = false packt sie in den App Store. In beiden Fällen liegt die Entscheidung bei dir in deiner eigenen Datei.
Config.AppList.Custom existiert, damit ein Serverbesitzer deine Vorgaben überstimmen kann — der App einen Preis geben, sie vorinstallieren oder deaktivieren. Für die bloße Funktion deiner App wird dieser Eintrag nie benötigt.
Vier Pflichten, die dir bei anderen Telefonen aufgebürdet werden, entfallen hier komplett:
- Du musst die App vor einer Aktualisierung nicht entfernen. Das erneute Registrieren derselben ID über dieselbe Ressource aktualisiert sie. Lediglich der Versuch einer fremden Ressource, eine vergebene ID zu beanspruchen, wird mit Begründung abgewiesen.
- Du musst nicht auf das Telefon warten.
mic_phone:readyfeuert, sobald Apps entgegengenommen werden können, und erneut nach jedem Neustart des Telefons; deine App registriert sich nach einemrestart mic_phonealso vollautomatisch neu. - Du musst beim Stoppen deiner Ressource nicht aufräumen. Deine Apps verschwinden mit ihr. Ein gestopptes Skript hinterlässt niemals funktionslose Icons.
- Du musst das Telefon nicht neu starten. Eine App, die registriert wird, während jemand sein Telefon in der Hand hält, erscheint augenblicklich auf dem Display.
Was eine App über sich angeben kann
Abschnitt betitelt „Was eine App über sich angeben kann“id | Buchstaben, Zahlen, _ und -. Serverweit eindeutig |
name | Beschriftung des Icons |
subtitle, description, features, version | Angaben für die App-Store-Seite |
developer, category | Anzeige im App Store |
color | Hintergrundfarbe des Icons, falls kein icon definiert wurde |
icon | eine URL. https://cfx-nui-<resource>/path.png liefert sie direkt aus deinem Ordner aus |
ui | resource/path/index.html, die Seite deiner App. Bei reinen Lua-Apps weglassen. Ein lokaler Pfad in einer Ressource dieses Servers, niemals eine externe URL — siehe unten |
landscape | Die Seite erfordert das entfaltete Tablet |
size, images | Präsentation im App Store. Bilder müssen über https:// eingebunden sein |
default | true platziert sie direkt auf dem Startbildschirm statt im App Store |
onOpen, onClose | Callbacks beim Öffnen und Schließen |
Du kannst deinen eigenen Preis nicht festlegen. Es gibt kein price-Feld, und zwar mit voller Absicht: Der Wert läge auf dem Client, und ein clientseitig deklarierter Preis lässt sich manipulieren — der App Store würde einen Betrag anzeigen, der Server jedoch null berechnen. Die Preisgestaltung obliegt Config.AppList.Custom, wird vom Serverbesitzer hinterlegt und serverseitig gelesen. Wenn deine App etwas kosten soll, bitte um die Ergänzung dieser einen Zeile.
Die Stadt hat das letzte Wort. Alles, was du hier anfragst, ist ein Vorschlag. Config.AppList.Custom in der Telefon-Config kann deine App bepreisen, vorinstallieren oder komplett abschalten, ohne dass jemand dein Skript anfassen muss. So muss kein Serverbesitzer einen Fork erstellen, nur um Gebühren zu verlangen.
Kommunikation mit deiner Seite
Abschnitt betitelt „Kommunikation mit deiner Seite“Deine Seite läuft als Iframe. Sie kann nicht in das Telefon hineingreifen, Nachrichten mitlesen oder über ihr Rechteck hinauszeichnen; ebenso wenig greift das Telefon direkt in sie hinein. Sämtlicher Datenaustausch erfolgt über Nachrichten.
ui muss ein Pfad innerhalb einer Ressource auf diesem Server sein. Ein Frame verlangt, dass die Content-Security-Policy der Seite seinen Ursprung erlaubt. CSP bietet keine Möglichkeit, “jede beliebige cfx-nui--Ressource” freizugeben — Wildcards sind nur als vollständiges linkes Label erlaubt (https://cfx-nui-* ist syntaktisch unzulässig), weshalb die Direktive für dein Frame auf https: geöffnet werden muss. Da das sehr weit gefasst ist, schränkt das Telefon dies serverseitig wieder ein: Es bildet den Origin direkt aus deinem Ressourcenpfad und weist alles ab, was bereits als vollständige URL übergeben wird. Wenn du deine Seite hosten möchtest, liefere sie aus deinem eigenen Ressourcenordner aus.
In deiner HTML/JS-Seite, zwei Zeilen:
parent.postMessage({ type: 'phone:ready' }, '*'); // say you have loadedconst send = m => parent.postMessage({ type: 'phone:message', message: m }, '*');und lauschen:
window.addEventListener('message', e => { if (e.data?.type === 'phone:theme') document.documentElement.dataset.theme = e.data.theme; if (e.data?.type === 'phone:message') { /* from your Lua */ }});In deinem Lua-Code:
exports.mic_phone:SendAppMessage('phonedemo', { kind = 'hello' })
AddEventHandler(GetCurrentResourceName() .. ':phone:message', function(message) -- from your page. Routed here by the resource that owns the app, so nothing else can send theseend)Sende aus onOpen heraus, was du möchtest. Eine Nachricht an eine Seite, die noch lädt, wartet verlässlich ab und wird in exakter Reihenfolge zugestellt, sobald die Seite phone:ready meldet. Andere Telefone raten davon ab, weil Nachrichten und Frame-Ladevorgang dort kollidieren; hier ist das nicht der Fall, denn Daten beim Öffnen einer App bereitzustellen, ist das Naheliegendste überhaupt.
phone:theme liefert den Farbmodus des Telefons (light/dark) und dessen Sprache mit, sobald sich deine Seite meldet. Berücksichtige dies, damit deine App wie aus einem Guss wirkt und nicht wie ein nachträglich aufgesetzter Webauftritt.
Anfragen an das Telefon richten
Abschnitt betitelt „Anfragen an das Telefon richten“Deine Seite kann das Telefon anweisen, vertraute Aufgaben zu übernehmen — eine Benachrichtigung anzeigen, die Kamera öffnen, ein Foto oder einen Kontakt auswählen lassen. Fünfzehn Zeilen richten das Setup ein, danach genügt ein einfaches await:
let next = 1; const waiting = new Map();const ask = (what, args) => new Promise(resolve => { const id = next++; waiting.set(id, resolve); parent.postMessage({ type: 'phone:ask', id, what, with: args || {} }, '*');});window.addEventListener('message', e => { if (e.data?.type === 'phone:answer') { const done = waiting.get(e.data.id); if (done) { waiting.delete(e.data.id); done(e.data.value); } }});const me = await ask('me'); // { number, name }const photo = await ask('photos.pick'); // { id, url, width, height } or nullconst shot = await ask('camera.take'); // the same, from the real Cameraconst person = await ask('contacts.pick'); // { name, number } or nullawait ask('notify', { title: 'Field Notes', body: 'Something happened.' });await ask('toast', { text: 'Saved.' });const look = await ask('theme'); // { theme, language, tablet }await ask('close'); // put yourself away| Anfrage | Rückgabewert | |
|---|---|---|
me | { number, name } | was andere Telefone sehen, und sonst nichts über die Person |
notify | true | läuft unter deiner App; stummschalten in den Einstellungen schaltet dich stumm |
toast | true | kurze Statuszeile am unteren Bildschirmrand |
photos.pick | ein Foto oder null | öffnet die Galerie; null bedeutet ohne Auswahl geschlossen |
camera.take | ein Foto oder null | öffnet die vollwertige Kamera im Spiel und kehrt anschließend zurück |
contacts.pick | ein Kontakt oder null | öffnet eine Auswahlliste mit Kontakten |
theme | { theme, language, tablet } | dieselben Werte, die auch phone:theme bereitstellt |
close | true | kehrt zum Startbildschirm zurück |
Und Aktionen, die Daten an das Telefon übergeben:
await ask('message.compose', { number: '555-0110', text: 'Your order is ready.' });await ask('call.start', { number: '555-0110' });await ask('share.text', { text: 'Look at this.' }); // → { to, number } or nullawait ask('share.photo', { url: photo.url });await ask('photos.save', { url: someImageUrl }); // → { id, width, height } or nullawait ask('waypoint.set', { x: 219, y: -855 });const money = await ask('money.balance'); // { balance }| Anfrage | Rückgabewert | |
|---|---|---|
message.compose | true | öffnet Nachrichten mit vorgefertigtem Text. Ein Mensch drückt Senden |
call.start | true | wählt die Nummer |
share.text / share.photo | { to, number } oder null | öffnet Auswahldialog und bereitet die Nachricht an den Gewählten vor |
photos.save | { id, width, height } | speichert ein Bild deiner App in Fotos, analog zu einem Kameraschuss |
waypoint.set | true | setzt einen Wegpunkt auf der Karte |
money.balance | { balance } | nur lesend — Geldtransfers laufen serverseitig (siehe unten) |
Nichts wird ungefragt im Namen eines Spielers versendet. message.compose füllt das Textfeld aus und überlässt das Senden dem Spieler; Teilen fragt jedes Mal nach dem Empfänger. Eine App, die unbemerkt SMS an Kontakte verschicken könnte, wäre ein untragbares Sicherheitsrisiko.
Finanzen
Abschnitt betitelt „Finanzen“Geldtransaktionen werden ausschließlich serverseitig über dein eigenes Server-Skript abgewickelt, da ein Client Preise manipulieren könnte. Drei Methoden stehen bereit, je nach Auslöser der Transaktion:
-- somebody else already took the money; this only writes the line in the Walletexports.mic_phone:AddTransaction(src, { title = 'Tequi-la-la', detail = 'Two drinks', amount = -40 })
-- take it, and write the line. false, 'funds' and nothing moved when they cannot paylocal ok, tx = exports.mic_phone:Charge(src, 250, { title = 'Field Notes', detail = 'Pro upgrade' })
-- give it, and write the lineexports.mic_phone:Pay(src, 1000, { title = 'Delivery', detail = 'Job finished' })
exports.mic_phone:GetBalance(src)Alle drei aktualisieren das Wallet auf dem Bildschirm sofort. Charge und Pay arbeiten atomar: Eine abgewiesene Abbuchung lässt Guthaben und Historie exakt unberührt.
Zwei Sicherheitsprinzipien erklären, warum die API bewusst kompakt gehalten ist:
Deine App erhält niemals Daten, die ihr nicht aktiv übergeben wurden. contacts.pick gibt nicht das Adressbuch frei — es öffnet ein Auswahlfenster, die Person tippt einen Kontakt an, und nur dieser eine Kontakt wird zurückgeliefert. Wiederholtes Abfragen liest nicht das Telefon aus; jede Antwort erfordert einen Klick. Dasselbe gilt für Fotos.
Nur die gerade sichtbare App darf Anfragen stellen. Eine Anfrage aus einem Frame, der nicht die aktive App ist, bleibt unbeantwortet und öffnet nichts. Eine im Hintergrund verbliebene Seite kann also nichts anstellen, während der Spieler anderweitig beschäftigt ist.
Falls etwas Benötigtes in dieser Liste fehlt, liegt das nicht an einer Blockade des Telefons — es wurde schlicht noch nicht ergänzt. Eine neue Aktion erfordert lediglich einen Eintrag in PHONE_APP_ASKS in html/apps/store/app-api.js.
Informationen über das Telefon abfragen
Abschnitt betitelt „Informationen über das Telefon abfragen“exports.mic_phone:RegisteredApps() --> every app registered on this client, and by whomexports.mic_phone:IsAppOpen('phonedemo') --> is it on screenexports.mic_phone:OpenAppId() --> which custom app is, or nilexports.mic_phone:OpenApp('phonedemo') --> open it, if the phone will open at allRegisteredApps ist die erste Anlaufstelle, wenn deine App nicht angezeigt wird. Der Konsolenbefehl phoneapps gibt aus, was die Config vorsieht — das ist eine andere Liste: Deine App lebt auf dem Client, der sie registriert hat, und erscheint in phoneapps nur, wenn jemand dafür einen Eintrag in Config.AppList.Custom hinterlegt hat.
Eine App, die weder auf default = true noch in der Config auf installed steht, verweilt im App Store, bis ein Spieler sie herunterlädt. Wenn deine App registriert ist, aber auf dem Startbildschirm fehlt, ist das fast immer der Grund.
Wenn etwas nicht funktioniert
Abschnitt betitelt „Wenn etwas nicht funktioniert“| Was du siehst | Fast immer die Ursache |
|---|---|
| Die App ist nirgends auffindbar | Zuerst RegisteredApps() prüfen. Fehlt sie dort, wurde AddApp abgewiesen — die Begründung steht in der Client-Konsole (F8), nicht auf dem Server |
| Registriert, aber fehlt auf Startbildschirm | Sie liegt im App Store: Du hast default = false gesetzt, oder der Serverbesitzer hat How = 'store' in Config.AppList.Custom gewählt |
| Die App öffnet sich als weiße Seite | Deine ui-Datei fehlt im files-Block deines fxmanifest |
Refused to frame in der Konsole | Dein ui-Pfad ist eine URL. Es muss ein Pfad innerhalb einer Ressource auf diesem Server sein |
| Seite lädt, aber Nachrichten kommen nie an | Deine Seite hat nie phone:ready gesendet. Vorher wird nichts zugestellt — Nachrichten warten ab und treffen gesammelt ein, sobald du es sendest |
no answer in 12000ms | Ein Aufruf an eine nicht existierende Telefonfunktion. Gleiche den Namen mit der obigen Liste ab |
phoneapps auf dem Server zeigt die Config, nicht deine App — eine per Skript registrierte App lebt auf dem registrierenden Client und taucht dort nur auf, wenn ein Config.AppList.Custom-Eintrag existiert. exports.mic_phone:RegisteredApps() liefert die tatsächliche Liste dieses Rechners.
Die restliche API des Telefons — State Bags, Anrufe, Kontakte, Benachrichtigungen — findest du unter Exports und State Bags.