Zum Inhalt springen

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.lua
fx_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 phone

Das 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:ready feuert, sobald Apps entgegengenommen werden können, und erneut nach jedem Neustart des Telefons; deine App registriert sich nach einem restart mic_phone also 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.
idBuchstaben, Zahlen, _ und -. Serverweit eindeutig
nameBeschriftung des Icons
subtitle, description, features, versionAngaben für die App-Store-Seite
developer, categoryAnzeige im App Store
colorHintergrundfarbe des Icons, falls kein icon definiert wurde
iconeine URL. https://cfx-nui-<resource>/path.png liefert sie direkt aus deinem Ordner aus
uiresource/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
landscapeDie Seite erfordert das entfaltete Tablet
size, imagesPräsentation im App Store. Bilder müssen über https:// eingebunden sein
defaulttrue platziert sie direkt auf dem Startbildschirm statt im App Store
onOpen, onCloseCallbacks 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.

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 loaded
const 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 these
end)

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.

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 null
const shot = await ask('camera.take'); // the same, from the real Camera
const person = await ask('contacts.pick'); // { name, number } or null
await 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
AnfrageRückgabewert
me{ number, name }was andere Telefone sehen, und sonst nichts über die Person
notifytrueläuft unter deiner App; stummschalten in den Einstellungen schaltet dich stumm
toasttruekurze Statuszeile am unteren Bildschirmrand
photos.pickein Foto oder nullöffnet die Galerie; null bedeutet ohne Auswahl geschlossen
camera.takeein Foto oder nullöffnet die vollwertige Kamera im Spiel und kehrt anschließend zurück
contacts.pickein Kontakt oder nullöffnet eine Auswahlliste mit Kontakten
theme{ theme, language, tablet }dieselben Werte, die auch phone:theme bereitstellt
closetruekehrt 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 null
await ask('share.photo', { url: photo.url });
await ask('photos.save', { url: someImageUrl }); // → { id, width, height } or null
await ask('waypoint.set', { x: 219, y: -855 });
const money = await ask('money.balance'); // { balance }
AnfrageRückgabewert
message.composetrueöffnet Nachrichten mit vorgefertigtem Text. Ein Mensch drückt Senden
call.starttruewä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.settruesetzt 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.

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 Wallet
exports.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 pay
local ok, tx = exports.mic_phone:Charge(src, 250, { title = 'Field Notes', detail = 'Pro upgrade' })
-- give it, and write the line
exports.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.

exports.mic_phone:RegisteredApps() --> every app registered on this client, and by whom
exports.mic_phone:IsAppOpen('phonedemo') --> is it on screen
exports.mic_phone:OpenAppId() --> which custom app is, or nil
exports.mic_phone:OpenApp('phonedemo') --> open it, if the phone will open at all

RegisteredApps 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.

Was du siehstFast immer die Ursache
Die App ist nirgends auffindbarZuerst 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 StartbildschirmSie 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 SeiteDeine ui-Datei fehlt im files-Block deines fxmanifest
Refused to frame in der KonsoleDein ui-Pfad ist eine URL. Es muss ein Pfad innerhalb einer Ressource auf diesem Server sein
Seite lädt, aber Nachrichten kommen nie anDeine Seite hat nie phone:ready gesendet. Vorher wird nichts zugestellt — Nachrichten warten ab und treffen gesammelt ein, sobald du es sendest
no answer in 12000msEin 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.