537 lines
18 KiB
Markdown
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.
|