2026-04-14 17:41:39 +02:00

537 lines
18 KiB
Markdown

# 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
```lua
-- 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`! Use `100%` instead
- **DO NOT** use `px` or `rem` units! Use `%` and `em` ONLY
- Add `html, body { width: 100%; height: 100%; overflow: hidden; }`
- Your app will be displayed inside the phone screen, not fullscreen
> **WARNING:** Using `px`, `rem`, or `vh` units will cause your app to display incorrectly on the phone screen. Always use `%` for dimensions and `em` for font sizes, padding, margins, etc.
```html
<!-- 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)
```lua
-- 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)
```lua
-- 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
```lua
-- 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
```lua
-- 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
```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
```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
```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
```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
1. **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 `files` section of fxmanifest.lua
- Example: `icon = 'nui://my-phone-app/ui/icon.png'`
2. **HTML**: Pass HTML string directly to `ui` parameter, don't use `nui://` URL
3. **⚠️ CSS UNITS**:
- **ONLY use `%` and `em`** - DO NOT use `px`, `rem`, or `vh`
- Use `100%` instead of `100vh` for height
- Use `em` for font-size, padding, margin, border-radius, etc.
- Example: `font-size: 1em;` NOT `font-size: 16px;`
- Example: `padding: 1em;` NOT `padding: 16px;`
4. **Security**: Validate all data in server callbacks
5. **Theme**: Use `theme` value from `mphone:init` message for dark/light theme support
6. **Database**: ALWAYS use `server: true` for database operations
## When Resource Stops
Your app is automatically removed when your resource stops. This is handled by the `onResourceStop` event handler.