18 KiB
18 KiB
External Apps System - codem-phone
This system allows external FiveM resources to add custom apps inside the phone.
Quick Start
1. Client-side: Register Your App
-- client/main.lua
CreateThread(function()
-- Wait for codem-phone to start
while GetResourceState('codem-phone') ~= 'started' do
Wait(100)
end
Wait(1000) -- Small delay
-- Read HTML content
local htmlContent = LoadResourceFile(GetCurrentResourceName(), 'ui/index.html')
-- Register the app
exports['codem-phone']:AddCustomApp({
identifier = 'myapp', -- Unique ID (required)
name = 'My Custom App', -- Display name (required)
icon = 'nui://myresource/icon.png', -- Icon URL (optional)
ui = htmlContent, -- HTML content (required)
description = 'An awesome app', -- Description (optional)
defaultApp = false, -- Is default app? (optional)
notification = true, -- Show notifications? (optional)
onOpen = function() -- Called when app opens (optional)
print('App opened!')
end,
onClose = function() -- Called when app closes (optional)
print('App closed!')
end
})
end)
2. HTML/JS: Communicate with Phone
⚠️ IMPORTANT CSS RULES:
- DO NOT use
100vh! Use100%instead - DO NOT use
pxorremunits! Use%andemONLY - Add
html, body { width: 100%; height: 100%; overflow: hidden; } - Your app will be displayed inside the phone screen, not fullscreen
WARNING: Using
px,rem, orvhunits will cause your app to display incorrectly on the phone screen. Always use%for dimensions andemfor font sizes, padding, margins, etc.
<!-- ui/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
<style>
/* IMPORTANT: Always include these rules */
/* USE ONLY % and em - NO px, rem, or vh! */
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
padding: 1em; /* Use em, NOT px! */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 1em; /* Base font size */
background: #000;
color: #fff;
display: flex;
flex-direction: column;
}
</style>
</head>
<body>
<h1>My App</h1>
<p>Phone Number: <span id="phone">-</span></p>
<button onclick="closeApp()">Close</button>
<script>
let playerData = null;
let pendingCallbacks = {};
// Listen for messages from phone
window.addEventListener('message', function(event) {
const data = event.data;
if (!data || !data.type) return;
switch(data.type) {
case 'mphone:init':
// Called when phone opens this app
playerData = data.player;
document.getElementById('phone').textContent = data.player.phoneNumber;
break;
case 'mphone:callback:response':
// Callback response received
const cb = pendingCallbacks[data.callbackId];
if (cb) {
cb(data.result);
delete pendingCallbacks[data.callbackId];
}
break;
case 'mphone:playerData':
// Player data response
playerData = data.player;
break;
}
});
// Close the app
function closeApp() {
window.parent.postMessage({ type: 'mphone:close' }, '*');
}
// Show notification
function showNotification(header, message) {
window.parent.postMessage({
type: 'mphone:notification',
header: header,
message: message
}, '*');
}
// Set waypoint on map
function setWaypoint(x, y) {
window.parent.postMessage({
type: 'mphone:setWaypoint',
x: x,
y: y
}, '*');
}
// ═══════════════════════════════════════════════════════════
// CALLBACK SYSTEM - Send requests to Client or Server
// ═══════════════════════════════════════════════════════════
/**
* Send callback to Lua
* @param {string} action - Event action name
* @param {object} payload - Data to send
* @param {boolean} toServer - true = server event, false = client event
* @returns {Promise} - Response promise
*/
function sendCallback(action, payload, toServer = false) {
return new Promise((resolve) => {
const callbackId = 'cb_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
pendingCallbacks[callbackId] = resolve;
window.parent.postMessage({
type: 'mphone:callback',
action: action,
payload: payload || {},
callbackId: callbackId,
server: toServer // IMPORTANT: true = server, false = client
}, '*');
// 10 second timeout
setTimeout(() => {
if (pendingCallbacks[callbackId]) {
resolve({ success: false, error: 'Timeout' });
delete pendingCallbacks[callbackId];
}
}, 10000);
});
}
// ═══════════════════════════════════════════════════════════
// USAGE EXAMPLES
// ═══════════════════════════════════════════════════════════
// CLIENT event example (server: false or not specified)
async function getClientData() {
const result = await sendCallback('getLocalData', { key: 'value' }, false);
// This event goes to handler in client/main.lua
console.log('Client response:', result);
}
// SERVER event example (server: true)
async function getServerData() {
const result = await sendCallback('getPlayerStats', { playerId: 1 }, true);
// This event goes to handler in server/main.lua
console.log('Server response:', result);
}
// Database operation example (always use server: true)
async function saveToDatabase() {
const result = await sendCallback('saveData', {
key: 'score',
value: 100
}, true); // server: true is REQUIRED for database operations!
if (result.success) {
showNotification('Success', 'Data saved!');
}
}
</script>
</body>
</html>
3. Listen for Callback Events
Client-side Event (server: false)
-- client/main.lua
-- Event format: codem-phone:customApp:{identifier}:{action}
-- Note: Client events do NOT have source parameter!
AddEventHandler('codem-phone:customApp:myapp:getLocalData', function(payload, cb)
-- payload: data from app
-- cb: response function
local localData = {
playerPed = PlayerPedId(),
coords = GetEntityCoords(PlayerPedId())
}
cb({ success = true, data = localData })
end)
-- Example: Get vehicle list
AddEventHandler('codem-phone:customApp:myapp:getVehicles', function(payload, cb)
local vehicles = GetGamePool('CVehicle')
cb({ success = true, count = #vehicles })
end)
Server-side Event (server: true)
-- server/main.lua
-- Event format: codem-phone:customApp:{identifier}:{action}
-- Note: Server events HAVE source parameter!
AddEventHandler('codem-phone:customApp:myapp:getPlayerStats', function(source, payload, cb)
-- source: player server ID
-- payload: data from app
-- cb: response function
local playerName = GetPlayerName(source)
cb({
success = true,
name = playerName,
id = source
})
end)
-- Example: Save to database
AddEventHandler('codem-phone:customApp:myapp:saveData', function(source, payload, cb)
MySQL.insert('INSERT INTO app_data (player_id, data) VALUES (?, ?)',
{ source, json.encode(payload) },
function(insertId)
cb({ success = true, id = insertId })
end
)
end)
-- Example: Read from database
AddEventHandler('codem-phone:customApp:myapp:loadData', function(source, payload, cb)
MySQL.query('SELECT * FROM app_data WHERE player_id = ?', { source },
function(results)
cb({ success = true, data = results })
end
)
end)
4. Send Message to App
-- client/main.lua
-- Send message to app (when app is open)
exports['codem-phone']:SendCustomAppMessage('myapp', {
type = 'customEvent',
data = { key = 'value' }
})
server Parameter Summary
server Value |
Event Runs Where | source Parameter | Use Case |
|---|---|---|---|
false (default) |
Client (client/main.lua) | NO | Local data, ped info, coordinates |
true |
Server (server/main.lua) | YES | Database, player validation, money operations |
API Reference
Client Exports
-- Add app
exports['codem-phone']:AddCustomApp({
identifier = string, -- Unique ID (required)
name = string, -- Display name (required)
ui = string, -- HTML content (required)
icon = string, -- Icon URL (optional, default: 'apps/default.png')
description = string, -- Description (optional)
defaultApp = boolean, -- Is default app? (optional, default: false)
notification = boolean, -- Notification support (optional, default: true)
onOpen = function, -- Called when opened (optional)
onClose = function -- Called when closed (optional)
})
-- Remove app
exports['codem-phone']:RemoveCustomApp(identifier)
-- Send message to app
exports['codem-phone']:SendCustomAppMessage(identifier, message)
-- Get app info
local app = exports['codem-phone']:GetCustomApp(identifier)
-- Check if phone is open
local isOpen = exports['codem-phone']:IsPhoneOpen()
JavaScript Message Types (Sent to Parent)
| Type | Description | Parameters |
|---|---|---|
mphone:close |
Close the app | - |
mphone:notification |
Show notification | header, message |
mphone:callback |
Send callback | action, payload, callbackId, server |
mphone:navigate |
Navigate to another app | path |
mphone:setWaypoint |
Set waypoint on map | x, y |
mphone:getPlayer |
Request player data | - |
JavaScript Message Types (Sent to App)
| Type | Description | Content |
|---|---|---|
mphone:init |
When app opens | identifier, player, theme, language |
mphone:callback:response |
Callback response | callbackId, result |
mphone:playerData |
Player data | player |
| Custom types | Messages from Lua | Variable |
Example Resource Structure
my-phone-app/
├── fxmanifest.lua
├── client/
│ └── main.lua
├── server/
│ └── main.lua (optional - for server callbacks)
└── ui/
└── index.html
fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
author 'Your Name'
description 'Custom phone app'
version '1.0.0'
client_scripts {
'client/main.lua'
}
-- Add if using server callbacks
server_scripts {
'server/main.lua'
}
files {
'ui/**/*'
}
dependency 'codem-phone'
Full Example - Counter App
client/main.lua
local appRegistered = false
CreateThread(function()
while GetResourceState('codem-phone') ~= 'started' do
Wait(100)
end
Wait(1000)
local htmlContent = LoadResourceFile(GetCurrentResourceName(), 'ui/index.html')
local success, err = exports['codem-phone']:AddCustomApp({
identifier = 'counter',
name = 'Counter',
icon = 'nui://' .. GetCurrentResourceName() .. '/ui/icon.png',
ui = htmlContent,
onOpen = function()
print('[Counter] App opened')
end,
onClose = function()
print('[Counter] App closed')
end
})
if success then
appRegistered = true
end
end)
server/main.lua
local PlayerCounters = {}
-- Get counter
AddEventHandler('codem-phone:customApp:counter:getCounter', function(source, payload, cb)
cb({ success = true, count = PlayerCounters[source] or 0 })
end)
-- Increment counter
AddEventHandler('codem-phone:customApp:counter:increment', function(source, payload, cb)
PlayerCounters[source] = (PlayerCounters[source] or 0) + 1
cb({ success = true, count = PlayerCounters[source] })
end)
-- Decrement counter
AddEventHandler('codem-phone:customApp:counter:decrement', function(source, payload, cb)
PlayerCounters[source] = math.max(0, (PlayerCounters[source] or 0) - 1)
cb({ success = true, count = PlayerCounters[source] })
end)
ui/index.html
<!DOCTYPE html>
<html>
<head>
<style>
/* USE ONLY % and em - NO px, rem, or vh! */
html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
body {
display: flex; flex-direction: column; align-items: center;
justify-content: center; background: #1a1a2e; color: white;
font-family: sans-serif; font-size: 1em;
}
.count { font-size: 4.5em; margin: 1.25em 0; }
button { padding: 0.9em 1.9em; margin: 0.3em; font-size: 1.25em; cursor: pointer; }
</style>
</head>
<body>
<h1>Counter</h1>
<div class="count" id="count">0</div>
<div>
<button onclick="decrement()">-</button>
<button onclick="increment()">+</button>
</div>
<button onclick="closeApp()">Close</button>
<script>
let pendingCallbacks = {};
window.addEventListener('message', (e) => {
if (e.data.type === 'mphone:init') loadCounter();
if (e.data.type === 'mphone:callback:response') {
const cb = pendingCallbacks[e.data.callbackId];
if (cb) { cb(e.data.result); delete pendingCallbacks[e.data.callbackId]; }
}
});
function sendCallback(action, payload) {
return new Promise(resolve => {
const id = 'cb_' + Date.now();
pendingCallbacks[id] = resolve;
window.parent.postMessage({
type: 'mphone:callback',
action: action,
payload: payload || {},
callbackId: id,
server: true // Send to server!
}, '*');
setTimeout(() => { resolve({ success: false }); delete pendingCallbacks[id]; }, 10000);
});
}
async function loadCounter() {
const r = await sendCallback('getCounter');
if (r.success) document.getElementById('count').textContent = r.count;
}
async function increment() {
const r = await sendCallback('increment');
if (r.success) document.getElementById('count').textContent = r.count;
}
async function decrement() {
const r = await sendCallback('decrement');
if (r.success) document.getElementById('count').textContent = r.count;
}
function closeApp() {
window.parent.postMessage({ type: 'mphone:close' }, '*');
}
</script>
</body>
</html>
Tips
- Icon:
- Use 128x128 PNG for best results
- Use
nui://URL format:nui://your-resource-name/ui/icon.png - Make sure the icon file is included in your
filessection of fxmanifest.lua - Example:
icon = 'nui://my-phone-app/ui/icon.png'
- HTML: Pass HTML string directly to
uiparameter, don't usenui://URL - ⚠️ CSS UNITS:
- ONLY use
%andem- DO NOT usepx,rem, orvh - Use
100%instead of100vhfor height - Use
emfor font-size, padding, margin, border-radius, etc. - Example:
font-size: 1em;NOTfont-size: 16px; - Example:
padding: 1em;NOTpadding: 16px;
- ONLY use
- Security: Validate all data in server callbacks
- Theme: Use
themevalue frommphone:initmessage for dark/light theme support - Database: ALWAYS use
server: truefor database operations
When Resource Stops
Your app is automatically removed when your resource stops. This is handled by the onResourceStop event handler.